Skip to main content

xchecker_engine/orchestrator/
mod.rs

1//! Phase orchestrator for executing spec generation workflows
2//!
3//! This module provides the core orchestration logic that wires together
4//! the Phase trait, `ArtifactManager`, and Receipt system to execute
5//! phases end-to-end with proper error handling and state management.
6
7mod 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// Workflow types are internal - used by execute_complete_workflow which is also pub(crate)
19#[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
34/// Orchestrates the execution of spec generation phases.
35///
36/// This is the main entry point for running phases. It manages:
37/// - Phase execution with dependency validation
38/// - Artifact storage and receipt generation
39/// - Lock management for concurrent access
40/// - Error handling and partial artifact preservation
41///
42/// # API Stability and Usage Guidance
43///
44/// **Production code**: Use [`OrchestratorHandle`] for all production scenarios. It provides
45/// a stable, higher-level API designed for CLI commands and external integrations.
46///
47/// **Test code**: `PhaseOrchestrator` exposes internal helper methods (marked with `#[doc(hidden)]`)
48/// for white-box testing of phase orchestration logic. These methods are public to enable testing
49/// but are not part of the stable API and may change without notice.
50///
51/// **Stability notice**: Methods marked with `#[doc(hidden)]` are internal implementation details
52/// and may be narrowed to `pub(crate)` visibility in future versions. Do not rely on them in
53/// production code.
54///
55/// # Examples
56///
57/// Production usage (preferred):
58/// ```ignore
59/// let handle = OrchestratorHandle::new("my-spec")?;
60/// handle.execute_requirements_phase(&config).await?;
61/// ```
62///
63/// Test usage (white-box testing):
64/// ```ignore
65/// let orchestrator = PhaseOrchestrator::new("test-spec")?;
66/// orchestrator.validate_transition(PhaseId::Design)?;  // Test internal logic
67/// let config = OrchestratorConfig::default();
68/// orchestrator.execute_requirements_phase(&config).await?;
69/// ```
70pub struct PhaseOrchestrator {
71    spec_id: String,
72    artifact_manager: ArtifactManager,
73    receipt_manager: ReceiptManager,
74}
75
76/// Configuration for orchestrator execution.
77///
78/// Controls how phases are executed, including dry-run mode
79/// and various runtime settings passed via the config map.
80///
81/// # Common Config Keys
82/// - `model`: LLM model to use
83/// - `phase_timeout`: Timeout in seconds
84/// - `apply_fixups`: Whether to apply fixups or preview
85#[derive(Debug, Clone, Default)]
86pub struct OrchestratorConfig {
87    /// Whether to run in dry-run mode (no Claude calls)
88    pub dry_run: bool,
89    /// Additional configuration parameters
90    pub config: HashMap<String, String>,
91    /// Full configuration snapshot for LLM backends (when available).
92    ///
93    /// When set, this allows LLM backend construction to use the complete
94    /// configuration model instead of the flattened config map.
95    pub full_config: Option<crate::config::Config>,
96    /// Content selectors for packet building
97    ///
98    /// If `Some`, phases use these selectors when building packets.
99    /// If `None`, phases fall back to built-in selector defaults.
100    pub selectors: Option<Selectors>,
101    /// Enable strict validation for phase outputs.
102    ///
103    /// When `true`, validation failures (meta-summaries, too-short output,
104    /// missing required sections) become hard errors that fail the phase.
105    /// When `false`, validation issues are logged as warnings only.
106    pub strict_validation: bool,
107    /// Secret redactor built from the effective configuration.
108    ///
109    /// Used for both secret scanning and final-pass redaction of user-facing output (FR-SEC-19).
110    pub redactor: Arc<crate::redaction::SecretRedactor>,
111    /// Hooks configuration for pre/post phase scripts.
112    pub hooks: Option<HooksConfig>,
113}
114
115/// Phase timeout configuration with sensible defaults.
116///
117/// Enforces minimum and default timeout values to prevent
118/// runaway phase executions.
119#[derive(Debug, Clone)]
120pub struct PhaseTimeout {
121    /// Timeout duration for phase execution
122    pub duration: Duration,
123}
124
125impl PhaseTimeout {
126    /// Default timeout in seconds (10 minutes)
127    pub const DEFAULT_SECS: u64 = 600;
128
129    /// Minimum timeout in seconds (5 seconds)
130    pub const MIN_SECS: u64 = 5;
131
132    /// Create a `PhaseTimeout` with a specific duration in seconds
133    #[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    /// Create `PhaseTimeout` from configuration with validation
142    /// Reads from CLI args or config file, with fallback to default
143    #[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    /// Create a new orchestrator for the given spec ID.
157    ///
158    /// Acquires an exclusive lock on the spec directory to prevent concurrent modifications.
159    ///
160    /// # Errors
161    /// Returns error if lock cannot be acquired or artifact manager creation fails.
162    pub fn new(spec_id: &str) -> Result<Self> {
163        Self::new_with_force(spec_id, false)
164    }
165
166    /// Create a new orchestrator with optional force flag for lock override.
167    ///
168    /// Use with caution: forcing lock override can lead to race conditions if another
169    /// process is actively working on the spec.
170    ///
171    /// # Arguments
172    /// * `spec_id` - The spec identifier
173    /// * `force` - Whether to override existing locks
174    ///
175    /// # Errors
176    /// Returns error if artifact manager creation fails.
177    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    /// Create a read-only orchestrator that doesn't acquire locks.
191    ///
192    /// Use this for status queries and inspection operations that don't modify state.
193    /// This allows reading spec data while another process holds the write lock.
194    ///
195    /// # Errors
196    /// Returns error if artifact manager creation fails.
197    pub fn new_readonly(spec_id: &str) -> Result<Self> {
198        // For read-only access, we create the managers directly without locks
199        let base_path = crate::paths::spec_root(spec_id);
200
201        // Create a dummy artifact manager without lock for read-only operations
202        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    /// Check if we can resume from a specific phase
213    fn can_resume_from_phase(&self, phase_id: PhaseId) -> Result<bool> {
214        // Check dependencies are satisfied
215        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][..], // Can skip review/fixup
222        };
223
224        for dep_phase in deps {
225            if !self.artifact_manager.phase_completed(*dep_phase) {
226                return Ok(false);
227            }
228
229            // Also check that the dependency has a successful receipt
230            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    /// Validate phase transition from current state to target phase.
243    ///
244    /// Ensures that the workflow follows valid phase sequences and all dependencies
245    /// are satisfied before allowing execution. This implements FR-ORC-001 and FR-ORC-002.
246    ///
247    /// # Visibility and Stability
248    ///
249    /// This method is `pub` to enable white-box testing of internal phase orchestration
250    /// logic. It is **not** part of the stable public API. Production code should use
251    /// [`OrchestratorHandle`] instead.
252    ///
253    /// **Test-oriented API**: Exposed for integration tests that need to verify phase
254    /// transition logic directly. May be narrowed to `pub(crate)` in future versions.
255    ///
256    /// **Stability**: Internal implementation detail. Signature and behavior may change
257    /// without warning. Do not use in production code.
258    ///
259    /// # Arguments
260    /// * `target_phase` - The phase to validate transition to
261    ///
262    /// # Errors
263    /// Returns `XCheckerError::Phase` with specific guidance if:
264    /// - Transition violates phase sequencing rules
265    /// - Required dependencies are not satisfied
266    #[doc(hidden)]
267    pub fn validate_transition(&self, target_phase: PhaseId) -> Result<(), XCheckerError> {
268        // Get current phase from last successful receipt
269        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        // Define legal transitions
277        let legal_next_phases = match current_phase {
278            None => vec![PhaseId::Requirements], // Fresh spec can only start with Requirements
279            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], // Can re-run final
285        };
286
287        // Check if target phase is in the list of legal next phases
288        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        // Check dependencies are satisfied
301        self.check_dependencies_satisfied(target_phase)?;
302
303        Ok(())
304    }
305
306    /// Get the current phase from the last successful receipt
307    fn get_current_phase(&self) -> Result<Option<PhaseId>> {
308        // Check each phase in reverse order to find the last completed one
309        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) // No successful receipts found
327    }
328
329    /// Check that all dependencies for a phase are satisfied
330    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][..], // Can skip review/fixup
338        };
339
340        for dep_phase in deps {
341            // Check if we have a successful receipt for the dependency
342            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    /// Get the spec ID.
375    ///
376    /// Returns the identifier for the spec managed by this orchestrator.
377    #[must_use]
378    pub(crate) fn spec_id(&self) -> &str {
379        &self.spec_id
380    }
381
382    /// Returns a reference to the artifact manager. Used by status generation and tests.
383    #[must_use]
384    pub fn artifact_manager(&self) -> &ArtifactManager {
385        &self.artifact_manager
386    }
387
388    /// Returns a reference to the receipt manager. Used by status generation and tests.
389    #[must_use]
390    pub fn receipt_manager(&self) -> &ReceiptManager {
391        &self.receipt_manager
392    }
393
394    /// Returns the current phase from last successful receipt.
395    ///
396    /// # Visibility and Stability
397    ///
398    /// This method is `pub` to enable white-box testing of phase state detection.
399    /// It is **not** part of the stable public API. Production code should use
400    /// [`OrchestratorHandle`] instead.
401    ///
402    /// **Test-oriented API**: Exposed for tests that need to verify current phase
403    /// state detection logic. May be narrowed to `pub(crate)` in future versions.
404    ///
405    /// **Stability**: Internal implementation detail. Signature and behavior may change
406    /// without warning. Do not use in production code.
407    #[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    /// Checks if resume from a given phase is valid.
414    ///
415    /// # Visibility and Stability
416    ///
417    /// This method is `pub` to enable white-box testing of resume validation logic.
418    /// It is **not** part of the stable public API. Production code should use
419    /// [`OrchestratorHandle`] instead.
420    ///
421    /// **Test-oriented API**: Exposed for tests that need to verify resume capability
422    /// checking. May be narrowed to `pub(crate)` in future versions.
423    ///
424    /// **Stability**: Internal implementation detail. Signature and behavior may change
425    /// without warning. Do not use in production code.
426    #[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    // Global lock for tests that mutate process-global state (env vars, cwd).
445    // Any test that uses `TempDirGuard` will be serialized.
446    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)] // Test helper: kept for future test expansion
456    fn setup_test_environment() -> (PhaseOrchestrator, TempDir) {
457        let _lock = orchestrator_env_guard();
458        let temp_dir = TempDir::new().unwrap();
459
460        // Change to temp directory for test
461        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        // Restore original directory
467        env::set_current_dir(original_dir).unwrap();
468
469        (orchestrator, temp_dir)
470    }
471
472    #[allow(dead_code)] // Test helper: kept for future test expansion
473    fn setup_test_environment_with_cleanup() -> (PhaseOrchestrator, TempDir) {
474        let _lock = orchestrator_env_guard();
475        let temp_dir = TempDir::new().unwrap();
476
477        // Change to temp directory for test and keep it there
478        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)] // Test helper: kept for future test expansion
486    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        // Store original directory and change to temp directory
491        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        // Create the orchestrator in the temp directory context
497        let orchestrator = match PhaseOrchestrator::new(&spec_id) {
498            Ok(orch) => orch,
499            Err(e) => {
500                // Restore directory before panicking
501                env::set_current_dir(original_dir).unwrap();
502                panic!("Failed to create orchestrator: {e}");
503            }
504        };
505
506        // Keep the temp directory as current for the test duration
507        // The test will need to restore it manually or use a guard
508
509        (orchestrator, temp_dir)
510    }
511
512    struct TempDirGuard {
513        // Hold the lock for the entire lifetime of the guard
514        _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            // _lock field drops last, releasing the mutex
524        }
525    }
526
527    fn setup_test_with_guard(test_name: &str) -> (PhaseOrchestrator, TempDirGuard) {
528        // Take the global lock first
529        let lock = orchestrator_env_guard();
530
531        // Isolate home directory to prevent cross-test contamination
532        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        // Use unique spec ID per test to avoid conflicts
540        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        // Test orchestrator creation logic without file system operations
556        // This test verifies the basic structure and configuration
557        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        // Test phase context structure without file system operations
594        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        // Test dependency checking logic without requiring file system
614        let requirements_phase = RequirementsPhase::new();
615        let design_phase = crate::phases::DesignPhase::new();
616
617        // Requirements has no dependencies
618        assert_eq!(requirements_phase.deps().len(), 0);
619
620        // Design depends on Requirements
621        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        // Test Claude response simulation logic without file system operations
628        // This is a pure unit test that doesn't require orchestrator setup
629
630        // Test Requirements phase simulation
631        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        // Verify the simulated response has expected structure
655        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        // Test ExecutionResult structure without requiring orchestrator
665        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        // Test that secret scanning happens before Claude invocation
685        // and prevents execution with proper error receipt
686
687        let (orchestrator, _guard) = setup_test_with_guard("secret-scan");
688
689        // Create a custom phase that includes a secret in the packet
690        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                // Create a packet with a GitHub PAT secret
711                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        // Execute the phase - should fail with secret detection
745        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        // Verify the execution failed due to secret detection
751        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        // Verify receipt was written
760        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        // Test that PacketEvidence in receipts contains actual file list
769        // from the packet that was created
770
771        let (orchestrator, _guard) = setup_test_with_guard("packet-evidence");
772
773        // Create a custom phase that includes file evidence in the packet
774        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                // Create evidence with specific files
798                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                // Generate a simple artifact
834                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(), // Will be computed during storage
839                };
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        // Execute the phase
865        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        // Read the receipt and verify packet evidence
872        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        // Check that packet (packet evidence) contains our files
878        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        // Verify first file
886        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        // Verify second file
892        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}