Implement structured approval workflows for pattern promotion with full audit trails for SOC 2 compliance. Core Components: - governance/types.rs: ApprovalRequest, ApprovalStatus, ApprovalDecision - governance/workflow.rs: ApprovalWorkflow, ApprovalStage with escalation - governance/store.rs: JSONL persistence for requests and decisions - governance/state_machine.rs: Approval state transitions with auto-advance - governance/audit.rs: AuditTrail with JSON/CSV/Markdown export CLI Commands: - aphoria governance pending/approve/reject/escalate/status/create - aphoria audit trail/export/summary Integration: - Pipeline gate blocks promotion until governance approval - Auto-creates approval requests when governance enabled - Evidence-based auto-approval for high-confidence patterns Also includes: - Phase 11-13: Evidence, Lifecycle, Scope modules - 62+ governance-specific tests (946 total passing) - Clippy clean with -D warnings - Refactored cli.rs into submodules (governance, lifecycle, scope, etc.) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
628 lines
22 KiB
Rust
628 lines
22 KiB
Rust
//! Promotion pipeline for converting learned patterns to declarative extractors.
|
|
//!
|
|
//! Orchestrates the full promotion flow: candidates → regex generation → validation → YAML output.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use tracing::{debug, info, warn};
|
|
use uuid::Uuid;
|
|
|
|
/// Result of smart autonomous promotion.
|
|
#[derive(Debug, Default)]
|
|
pub struct SmartPromotionResult {
|
|
/// Number of patterns auto-promoted (no human review).
|
|
pub auto_promoted: usize,
|
|
/// Number of patterns that require human review.
|
|
pub requires_review: usize,
|
|
/// Paths to promoted YAML files.
|
|
pub promoted_files: Vec<PathBuf>,
|
|
/// Errors encountered during processing.
|
|
pub errors: Vec<AphoriaError>,
|
|
}
|
|
|
|
use super::audit::AutonomousAuditLog;
|
|
use super::regex_gen::RegexGenerator;
|
|
use super::types::{PromotionCandidate, PromotionStats, ValidationResult};
|
|
use super::validator::ExtractorValidator;
|
|
use super::writer::YamlWriter;
|
|
use crate::config::{AutonomousConfig, GovernanceConfig, PromotionConfig};
|
|
use crate::governance::{ApprovalStatus, GovernanceStateMachine, GovernanceStore};
|
|
use crate::learning::{LearnedPattern, PatternStore};
|
|
use crate::llm::GeminiClient;
|
|
use crate::AphoriaError;
|
|
|
|
/// The promotion pipeline orchestrates pattern-to-extractor conversion.
|
|
pub struct PromotionPipeline<'a, S: PatternStore> {
|
|
/// Pattern store for fetching candidates.
|
|
store: &'a S,
|
|
|
|
/// LLM client for regex generation.
|
|
client: Option<&'a GeminiClient>,
|
|
|
|
/// Configuration for promotion thresholds.
|
|
config: &'a PromotionConfig,
|
|
|
|
/// Validator for testing generated extractors.
|
|
validator: ExtractorValidator,
|
|
|
|
/// YAML writer for output.
|
|
writer: Option<YamlWriter>,
|
|
}
|
|
|
|
impl<'a, S: PatternStore> PromotionPipeline<'a, S> {
|
|
/// Create a new promotion pipeline.
|
|
///
|
|
/// If `output_dir` is None, uses the default `.aphoria/extractors/learned/`.
|
|
pub fn new(
|
|
store: &'a S,
|
|
client: Option<&'a GeminiClient>,
|
|
config: &'a PromotionConfig,
|
|
output_dir: Option<PathBuf>,
|
|
) -> Result<Self, AphoriaError> {
|
|
let writer = if let Some(dir) = output_dir { Some(YamlWriter::new(dir)?) } else { None };
|
|
|
|
Ok(Self { store, client, config, validator: ExtractorValidator::default(), writer })
|
|
}
|
|
|
|
/// Get patterns eligible for promotion.
|
|
///
|
|
/// Returns patterns that meet the configured thresholds for project count
|
|
/// and confidence.
|
|
pub fn get_candidates(&self) -> Vec<LearnedPattern> {
|
|
self.store.get_promotion_candidates(self.config.min_projects, self.config.min_confidence)
|
|
}
|
|
|
|
/// Generate a promotion candidate from a learned pattern.
|
|
///
|
|
/// Uses the LLM to generate a regex pattern and validates it.
|
|
pub fn generate_candidate(
|
|
&self,
|
|
pattern: &LearnedPattern,
|
|
) -> Result<PromotionCandidate, AphoriaError> {
|
|
let client = self.client.ok_or_else(|| {
|
|
AphoriaError::Promotion("LLM client not configured for regex generation".to_string())
|
|
})?;
|
|
|
|
// Generate extractor definition using LLM
|
|
let generator = RegexGenerator::new(client);
|
|
let extractor_def = generator.generate(pattern)?;
|
|
|
|
// Validate the generated extractor
|
|
let validation = self.validator.validate(&extractor_def, pattern)?;
|
|
|
|
Ok(PromotionCandidate::new(pattern.clone(), extractor_def, validation))
|
|
}
|
|
|
|
/// Promote a candidate by writing it to YAML and marking the pattern as promoted.
|
|
///
|
|
/// Returns the path to the written YAML file.
|
|
///
|
|
/// If governance is enabled, this will check for an approved governance request.
|
|
/// If no request exists or it's not approved, the promotion will be blocked.
|
|
pub fn promote(&self, candidate: &PromotionCandidate) -> Result<PathBuf, AphoriaError> {
|
|
self.promote_with_governance(candidate, None)
|
|
}
|
|
|
|
/// Promote with optional governance configuration.
|
|
///
|
|
/// When `governance_config` is provided and enabled, checks for governance approval.
|
|
pub fn promote_with_governance(
|
|
&self,
|
|
candidate: &PromotionCandidate,
|
|
governance_config: Option<&GovernanceConfig>,
|
|
) -> Result<PathBuf, AphoriaError> {
|
|
// Check if candidate is ready
|
|
if !candidate.is_ready() {
|
|
return Err(AphoriaError::Promotion(format!(
|
|
"Candidate {} is not ready for promotion: validation={}, performance={}",
|
|
candidate.pattern_id(),
|
|
candidate.validation.passed,
|
|
candidate.validation.performance_ok
|
|
)));
|
|
}
|
|
|
|
// Check governance if enabled
|
|
if let Some(gov_config) = governance_config {
|
|
if gov_config.enabled {
|
|
self.check_governance_approval(candidate, gov_config)?;
|
|
}
|
|
}
|
|
|
|
// Get or create writer
|
|
let writer = if let Some(ref w) = self.writer {
|
|
w
|
|
} else {
|
|
return Err(AphoriaError::Promotion("YAML writer not configured".to_string()));
|
|
};
|
|
|
|
// Check if already exists
|
|
if writer.exists(candidate.extractor_name()) {
|
|
return Err(AphoriaError::Promotion(format!(
|
|
"Extractor '{}' already exists",
|
|
candidate.extractor_name()
|
|
)));
|
|
}
|
|
|
|
// Write YAML file
|
|
let path = writer.write(&candidate.extractor_def, &candidate.pattern)?;
|
|
|
|
// Mark pattern as promoted
|
|
self.store.mark_promoted(&candidate.pattern_id(), candidate.extractor_name())?;
|
|
|
|
info!(
|
|
pattern_id = %candidate.pattern_id(),
|
|
extractor = %candidate.extractor_name(),
|
|
path = %path.display(),
|
|
"Pattern promoted to extractor"
|
|
);
|
|
|
|
Ok(path)
|
|
}
|
|
|
|
/// Check if a pattern has governance approval for promotion.
|
|
fn check_governance_approval(
|
|
&self,
|
|
candidate: &PromotionCandidate,
|
|
governance_config: &GovernanceConfig,
|
|
) -> Result<(), AphoriaError> {
|
|
let store = GovernanceStore::open_default()?;
|
|
let pattern_id = candidate.pattern_id();
|
|
|
|
match store.get_request_by_pattern(&pattern_id)? {
|
|
Some(request) => {
|
|
match &request.status {
|
|
ApprovalStatus::Approved => {
|
|
// Approved - can proceed with promotion
|
|
debug!(
|
|
pattern_id = %pattern_id,
|
|
request_id = %request.id,
|
|
"Governance approval verified"
|
|
);
|
|
Ok(())
|
|
}
|
|
ApprovalStatus::Pending { stage } => Err(AphoriaError::Promotion(format!(
|
|
"Pattern awaiting governance approval at stage '{}'. Request ID: {}",
|
|
stage, request.id
|
|
))),
|
|
ApprovalStatus::Rejected { stage, reason } => {
|
|
Err(AphoriaError::Promotion(format!(
|
|
"Pattern was rejected at stage '{}': {}. Request ID: {}",
|
|
stage, reason, request.id
|
|
)))
|
|
}
|
|
ApprovalStatus::Escalated { from_stage, to_stage } => {
|
|
Err(AphoriaError::Promotion(format!(
|
|
"Pattern was escalated from '{}' to '{}'. Request ID: {}",
|
|
from_stage, to_stage, request.id
|
|
)))
|
|
}
|
|
ApprovalStatus::Expired => Err(AphoriaError::Promotion(format!(
|
|
"Pattern approval request expired. Create a new request. Request ID: {}",
|
|
request.id
|
|
))),
|
|
}
|
|
}
|
|
None => {
|
|
// No approval request exists - create one
|
|
self.create_governance_request(candidate, governance_config)?;
|
|
Err(AphoriaError::Promotion(
|
|
"Approval request created. Pattern awaiting governance review.".to_string(),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a governance approval request for a pattern.
|
|
fn create_governance_request(
|
|
&self,
|
|
candidate: &PromotionCandidate,
|
|
governance_config: &GovernanceConfig,
|
|
) -> Result<(), AphoriaError> {
|
|
let sm = GovernanceStateMachine::open_default(governance_config.clone())?;
|
|
|
|
// Get the appropriate workflow
|
|
let workflow = sm.get_workflow_for_pattern(&candidate.pattern).ok_or_else(|| {
|
|
AphoriaError::Promotion(
|
|
"No governance workflow configured for this pattern".to_string(),
|
|
)
|
|
})?;
|
|
|
|
// Create the request
|
|
let creator = whoami::username();
|
|
let request = sm.create_request(&candidate.pattern, &workflow, &creator)?;
|
|
|
|
info!(
|
|
pattern_id = %candidate.pattern_id(),
|
|
request_id = %request.id,
|
|
workflow = %workflow.name,
|
|
"Created governance approval request"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Process all eligible patterns and return promotion candidates.
|
|
///
|
|
/// Generates and validates extractors for each eligible pattern.
|
|
/// Does not actually promote (write YAML) - use `promote()` for that.
|
|
pub fn process_all(&self) -> Vec<Result<PromotionCandidate, AphoriaError>> {
|
|
let patterns = self.get_candidates();
|
|
|
|
debug!(count = patterns.len(), "Processing promotion candidates");
|
|
|
|
patterns.iter().map(|pattern| self.generate_candidate(pattern)).collect()
|
|
}
|
|
|
|
/// Auto-promote all ready candidates.
|
|
///
|
|
/// Only runs if `auto_promote` is enabled in config.
|
|
/// Returns the number of patterns promoted and any errors.
|
|
pub fn auto_promote_all(&self) -> (usize, Vec<AphoriaError>) {
|
|
if !self.config.auto_promote {
|
|
warn!("auto_promote is disabled in config");
|
|
return (0, vec![]);
|
|
}
|
|
|
|
let candidates = self.process_all();
|
|
let mut promoted = 0;
|
|
let mut errors = Vec::new();
|
|
|
|
for result in candidates {
|
|
match result {
|
|
Ok(candidate) if candidate.is_ready() => match self.promote(&candidate) {
|
|
Ok(_) => promoted += 1,
|
|
Err(e) => errors.push(e),
|
|
},
|
|
Ok(candidate) => {
|
|
debug!(
|
|
pattern_id = %candidate.pattern_id(),
|
|
"Candidate not ready for auto-promotion"
|
|
);
|
|
}
|
|
Err(e) => errors.push(e),
|
|
}
|
|
}
|
|
|
|
(promoted, errors)
|
|
}
|
|
|
|
/// Smart auto-promote with autonomous decision logic.
|
|
///
|
|
/// Unlike `auto_promote_all()` which uses the basic `auto_promote` flag,
|
|
/// this method applies stricter thresholds from `AutonomousConfig` and
|
|
/// logs all decisions to an audit trail.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// A tuple of (auto_promoted_count, requires_review_count, errors).
|
|
///
|
|
/// # Behavior
|
|
///
|
|
/// For each eligible candidate:
|
|
/// 1. Checks `should_auto_promote()` against autonomous thresholds
|
|
/// 2. If eligible: promotes and logs "auto_promoted" decision
|
|
/// 3. If not eligible: logs "requires_review" decision with blockers
|
|
pub fn smart_auto_promote_all(
|
|
&self,
|
|
autonomous_config: &AutonomousConfig,
|
|
) -> Result<SmartPromotionResult, AphoriaError> {
|
|
let mut result = SmartPromotionResult::default();
|
|
|
|
// Check kill switch
|
|
if !autonomous_config.enabled {
|
|
warn!("Autonomous promotion is disabled (kill switch is off)");
|
|
return Ok(result);
|
|
}
|
|
|
|
// Create audit log if enabled
|
|
let audit_log = if autonomous_config.audit_log {
|
|
Some(AutonomousAuditLog::new(autonomous_config.audit_dir.as_ref())?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Process all candidates
|
|
let candidates = self.process_all();
|
|
|
|
for candidate_result in candidates {
|
|
match candidate_result {
|
|
Ok(candidate) => {
|
|
if candidate.should_auto_promote(autonomous_config) {
|
|
// Promote autonomously
|
|
match self.promote_autonomous(&candidate) {
|
|
Ok(path) => {
|
|
result.auto_promoted += 1;
|
|
result.promoted_files.push(path.clone());
|
|
|
|
// Log the decision
|
|
if let Some(ref log) = audit_log {
|
|
if let Err(e) =
|
|
log.record_promoted(&candidate, autonomous_config, path)
|
|
{
|
|
warn!(error = %e, "Failed to record audit log");
|
|
}
|
|
}
|
|
|
|
info!(
|
|
pattern_id = %candidate.pattern_id(),
|
|
extractor = %candidate.extractor_name(),
|
|
"Autonomously promoted pattern"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
result.errors.push(e);
|
|
}
|
|
}
|
|
} else {
|
|
// Requires human review
|
|
result.requires_review += 1;
|
|
|
|
// Log the decision with blockers
|
|
if let Some(ref log) = audit_log {
|
|
if let Err(e) =
|
|
log.record_requires_review(&candidate, autonomous_config)
|
|
{
|
|
warn!(error = %e, "Failed to record audit log");
|
|
}
|
|
}
|
|
|
|
debug!(
|
|
pattern_id = %candidate.pattern_id(),
|
|
blockers = ?candidate.auto_promotion_blockers(autonomous_config),
|
|
"Pattern requires human review"
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
result.errors.push(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
/// Promote a candidate autonomously (sets auto_promoted metadata).
|
|
fn promote_autonomous(&self, candidate: &PromotionCandidate) -> Result<PathBuf, AphoriaError> {
|
|
// Check if candidate is ready
|
|
if !candidate.is_ready() {
|
|
return Err(AphoriaError::Promotion(format!(
|
|
"Candidate {} is not ready for promotion: validation={}, performance={}",
|
|
candidate.pattern_id(),
|
|
candidate.validation.passed,
|
|
candidate.validation.performance_ok
|
|
)));
|
|
}
|
|
|
|
// Get or create writer
|
|
let writer = if let Some(ref w) = self.writer {
|
|
w
|
|
} else {
|
|
return Err(AphoriaError::Promotion("YAML writer not configured".to_string()));
|
|
};
|
|
|
|
// Check if already exists
|
|
if writer.exists(candidate.extractor_name()) {
|
|
return Err(AphoriaError::Promotion(format!(
|
|
"Extractor '{}' already exists",
|
|
candidate.extractor_name()
|
|
)));
|
|
}
|
|
|
|
// Write YAML file with autonomous metadata
|
|
let path = writer.write_autonomous(&candidate.extractor_def, &candidate.pattern)?;
|
|
|
|
// Mark pattern as promoted
|
|
self.store.mark_promoted(&candidate.pattern_id(), candidate.extractor_name())?;
|
|
|
|
Ok(path)
|
|
}
|
|
|
|
/// Get statistics about the promotion pipeline.
|
|
pub fn stats(&self) -> PromotionStats {
|
|
let all_patterns: Vec<LearnedPattern> = self.store.get_promotion_candidates(0, 0.0); // Get all patterns
|
|
|
|
let eligible = self.get_candidates();
|
|
let promoted: Vec<_> = all_patterns.iter().filter(|p| p.promoted).collect();
|
|
|
|
let avg_confidence = if eligible.is_empty() {
|
|
0.0
|
|
} else {
|
|
eligible.iter().map(|p| p.avg_confidence).sum::<f32>() / eligible.len() as f32
|
|
};
|
|
|
|
let avg_projects = if eligible.is_empty() {
|
|
0.0
|
|
} else {
|
|
eligible.iter().map(|p| p.project_count() as f32).sum::<f32>() / eligible.len() as f32
|
|
};
|
|
|
|
PromotionStats {
|
|
total_patterns: all_patterns.len(),
|
|
eligible_patterns: eligible.len(),
|
|
promoted_patterns: promoted.len(),
|
|
pending_review: eligible.len().saturating_sub(promoted.len()),
|
|
avg_confidence,
|
|
avg_projects,
|
|
}
|
|
}
|
|
|
|
/// Promote a specific pattern by ID.
|
|
pub fn promote_by_id(&self, pattern_id: &Uuid) -> Result<PathBuf, AphoriaError> {
|
|
// Find the pattern
|
|
let candidates = self.get_candidates();
|
|
let pattern = candidates.iter().find(|p| &p.id == pattern_id).ok_or_else(|| {
|
|
AphoriaError::Promotion(format!("Pattern {} not found in candidates", pattern_id))
|
|
})?;
|
|
|
|
// Generate and validate
|
|
let candidate = self.generate_candidate(pattern)?;
|
|
|
|
// Promote
|
|
self.promote(&candidate)
|
|
}
|
|
|
|
/// Validate a pattern without promoting it.
|
|
///
|
|
/// Returns the validation result for inspection.
|
|
pub fn validate_pattern(
|
|
&self,
|
|
pattern: &LearnedPattern,
|
|
) -> Result<ValidationResult, AphoriaError> {
|
|
let client = self.client.ok_or_else(|| {
|
|
AphoriaError::Promotion("LLM client not configured for regex generation".to_string())
|
|
})?;
|
|
|
|
let generator = RegexGenerator::new(client);
|
|
let extractor_def = generator.generate(pattern)?;
|
|
|
|
self.validator.validate(&extractor_def, pattern)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::PromotionConfig;
|
|
use crate::learning::{ClaimTemplate, LocalPatternStore, ValueType};
|
|
use crate::types::Language;
|
|
use chrono::Utc;
|
|
use tempfile::TempDir;
|
|
|
|
fn create_test_store(temp: &TempDir) -> LocalPatternStore {
|
|
LocalPatternStore::new(temp.path()).expect("create store")
|
|
}
|
|
|
|
fn create_eligible_pattern() -> LearnedPattern {
|
|
let mut pattern = LearnedPattern::new(
|
|
"verify_ssl = false",
|
|
"verify_ssl = <boolean>",
|
|
ClaimTemplate::new("ssl/verify", "enabled", ValueType::Boolean, "SSL verification"),
|
|
Language::Python,
|
|
"project1",
|
|
0.9,
|
|
);
|
|
|
|
// Add enough projects to meet threshold
|
|
for i in 2..=6 {
|
|
pattern.record_observation(format!("project{}", i), 0.85, Utc::now());
|
|
}
|
|
|
|
pattern
|
|
}
|
|
|
|
#[test]
|
|
fn test_pipeline_creation() {
|
|
let temp = TempDir::new().expect("temp dir");
|
|
let store = create_test_store(&temp);
|
|
let config = PromotionConfig::default();
|
|
|
|
let pipeline =
|
|
PromotionPipeline::new(&store, None, &config, Some(temp.path().to_path_buf()));
|
|
assert!(pipeline.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_candidates_empty() {
|
|
let temp = TempDir::new().expect("temp dir");
|
|
let store = create_test_store(&temp);
|
|
let config = PromotionConfig::default();
|
|
|
|
let pipeline =
|
|
PromotionPipeline::new(&store, None, &config, None).expect("create pipeline");
|
|
|
|
let candidates = pipeline.get_candidates();
|
|
assert!(candidates.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_candidates_with_eligible() {
|
|
let temp = TempDir::new().expect("temp dir");
|
|
let store = create_test_store(&temp);
|
|
let config = PromotionConfig::default();
|
|
|
|
// Add eligible pattern
|
|
let pattern = create_eligible_pattern();
|
|
store.record_pattern(&pattern, None).expect("record");
|
|
|
|
let pipeline =
|
|
PromotionPipeline::new(&store, None, &config, None).expect("create pipeline");
|
|
|
|
let candidates = pipeline.get_candidates();
|
|
assert_eq!(candidates.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_stats_empty_store() {
|
|
let temp = TempDir::new().expect("temp dir");
|
|
let store = create_test_store(&temp);
|
|
let config = PromotionConfig::default();
|
|
|
|
let pipeline =
|
|
PromotionPipeline::new(&store, None, &config, None).expect("create pipeline");
|
|
|
|
let stats = pipeline.stats();
|
|
assert_eq!(stats.total_patterns, 0);
|
|
assert_eq!(stats.eligible_patterns, 0);
|
|
assert_eq!(stats.promoted_patterns, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_stats_with_patterns() {
|
|
let temp = TempDir::new().expect("temp dir");
|
|
let store = create_test_store(&temp);
|
|
let config = PromotionConfig::default();
|
|
|
|
// Add eligible pattern
|
|
let pattern = create_eligible_pattern();
|
|
store.record_pattern(&pattern, None).expect("record");
|
|
|
|
// Add non-eligible pattern (not enough projects)
|
|
let small_pattern = LearnedPattern::new(
|
|
"test = true",
|
|
"test = <boolean>",
|
|
ClaimTemplate::new("test", "value", ValueType::Boolean, "Test"),
|
|
Language::Rust,
|
|
"project1",
|
|
0.9,
|
|
);
|
|
store.record_pattern(&small_pattern, None).expect("record");
|
|
|
|
let pipeline =
|
|
PromotionPipeline::new(&store, None, &config, None).expect("create pipeline");
|
|
|
|
let stats = pipeline.stats();
|
|
assert_eq!(stats.eligible_patterns, 1);
|
|
assert_eq!(stats.pending_review, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_candidate_requires_client() {
|
|
let temp = TempDir::new().expect("temp dir");
|
|
let store = create_test_store(&temp);
|
|
let config = PromotionConfig::default();
|
|
let pattern = create_eligible_pattern();
|
|
|
|
let pipeline =
|
|
PromotionPipeline::new(&store, None, &config, None).expect("create pipeline");
|
|
|
|
let result = pipeline.generate_candidate(&pattern);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("LLM client not configured"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_promote_disabled() {
|
|
let temp = TempDir::new().expect("temp dir");
|
|
let store = create_test_store(&temp);
|
|
let config = PromotionConfig { auto_promote: false, ..Default::default() };
|
|
|
|
let pipeline =
|
|
PromotionPipeline::new(&store, None, &config, None).expect("create pipeline");
|
|
|
|
let (promoted, errors) = pipeline.auto_promote_all();
|
|
assert_eq!(promoted, 0);
|
|
assert!(errors.is_empty());
|
|
}
|
|
}
|