use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::{
AgentConfig, EnvStoreConfig, ExecutionProfileConfig, HealthPolicyConfig, InvariantConfig,
StepTemplateConfig, TriggerConfig, WorkflowConfig,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SafetyConfig {
#[serde(default = "default_max_consecutive_failures")]
pub max_consecutive_failures: u32,
#[serde(default)]
pub auto_rollback: bool,
#[serde(default)]
pub checkpoint_strategy: CheckpointStrategy,
#[serde(default)]
pub step_timeout_secs: Option<u64>,
#[serde(default)]
pub binary_snapshot: bool,
#[serde(default)]
pub profile: WorkflowSafetyProfile,
#[serde(default)]
pub invariants: Vec<InvariantConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_spawned_tasks: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_spawn_depth: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spawn_cooldown_seconds: Option<u64>,
#[serde(default = "default_max_item_step_failures")]
pub max_item_step_failures: u32,
#[serde(default = "default_min_cycle_interval_secs")]
pub min_cycle_interval_secs: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stall_timeout_secs: Option<u64>,
#[serde(default = "default_inflight_wait_timeout_secs")]
pub inflight_wait_timeout_secs: u64,
#[serde(default = "default_inflight_heartbeat_grace_secs")]
pub inflight_heartbeat_grace_secs: u64,
}
fn default_max_consecutive_failures() -> u32 {
3
}
fn default_max_item_step_failures() -> u32 {
3
}
fn default_min_cycle_interval_secs() -> u64 {
60
}
fn default_inflight_wait_timeout_secs() -> u64 {
300
}
fn default_inflight_heartbeat_grace_secs() -> u64 {
60
}
impl Default for SafetyConfig {
fn default() -> Self {
Self {
max_consecutive_failures: 3,
auto_rollback: false,
checkpoint_strategy: CheckpointStrategy::default(),
step_timeout_secs: None,
binary_snapshot: false,
profile: WorkflowSafetyProfile::default(),
invariants: Vec::new(),
max_spawned_tasks: None,
max_spawn_depth: None,
spawn_cooldown_seconds: None,
stall_timeout_secs: None,
max_item_step_failures: 3,
min_cycle_interval_secs: 60,
inflight_wait_timeout_secs: 300,
inflight_heartbeat_grace_secs: 60,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CheckpointStrategy {
#[default]
None,
GitTag,
GitStash,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowSafetyProfile {
#[default]
Standard,
SelfReferentialProbe,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceConfig {
pub root_path: String,
pub qa_targets: Vec<String>,
pub ticket_dir: String,
#[serde(default)]
pub self_referential: bool,
#[serde(default, skip_serializing_if = "HealthPolicyConfig::is_default")]
pub health_policy: HealthPolicyConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub workspaces: HashMap<String, WorkspaceConfig>,
#[serde(default)]
pub agents: HashMap<String, AgentConfig>,
#[serde(default)]
pub workflows: HashMap<String, WorkflowConfig>,
#[serde(default)]
pub step_templates: HashMap<String, StepTemplateConfig>,
#[serde(default)]
pub env_stores: HashMap<String, EnvStoreConfig>,
#[serde(default)]
pub execution_profiles: HashMap<String, ExecutionProfileConfig>,
#[serde(default)]
pub triggers: HashMap<String, TriggerConfig>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_safety_config_default() {
let cfg = SafetyConfig::default();
assert_eq!(cfg.max_consecutive_failures, 3);
assert!(!cfg.auto_rollback);
assert!(matches!(cfg.checkpoint_strategy, CheckpointStrategy::None));
assert!(cfg.step_timeout_secs.is_none());
assert!(cfg.stall_timeout_secs.is_none());
assert!(!cfg.binary_snapshot);
assert_eq!(cfg.max_item_step_failures, 3);
assert_eq!(cfg.min_cycle_interval_secs, 60);
assert_eq!(cfg.inflight_wait_timeout_secs, 300);
assert_eq!(cfg.inflight_heartbeat_grace_secs, 60);
}
#[test]
fn test_safety_config_serde_round_trip() {
let cfg = SafetyConfig {
max_consecutive_failures: 5,
auto_rollback: true,
checkpoint_strategy: CheckpointStrategy::GitTag,
step_timeout_secs: Some(600),
binary_snapshot: true,
profile: WorkflowSafetyProfile::SelfReferentialProbe,
..SafetyConfig::default()
};
let json = serde_json::to_string(&cfg).expect("serialize safety config");
let cfg2: SafetyConfig = serde_json::from_str(&json).expect("deserialize safety config");
assert_eq!(cfg2.max_consecutive_failures, 5);
assert!(cfg2.auto_rollback);
assert!(matches!(
cfg2.checkpoint_strategy,
CheckpointStrategy::GitTag
));
assert_eq!(cfg2.step_timeout_secs, Some(600));
assert!(cfg2.binary_snapshot);
assert_eq!(cfg2.profile, WorkflowSafetyProfile::SelfReferentialProbe);
}
#[test]
fn test_safety_config_deserialize_minimal() {
let json = r#"{}"#;
let cfg: SafetyConfig = serde_json::from_str(json).expect("deserialize minimal safety");
assert_eq!(cfg.max_consecutive_failures, 3);
assert!(!cfg.auto_rollback);
assert_eq!(cfg.profile, WorkflowSafetyProfile::Standard);
assert_eq!(cfg.max_item_step_failures, 3);
assert_eq!(cfg.min_cycle_interval_secs, 60);
assert_eq!(cfg.inflight_wait_timeout_secs, 300);
assert_eq!(cfg.inflight_heartbeat_grace_secs, 60);
}
#[test]
fn test_fr052_fields_serde_round_trip() {
let cfg = SafetyConfig {
inflight_wait_timeout_secs: 600,
inflight_heartbeat_grace_secs: 120,
..SafetyConfig::default()
};
let json = serde_json::to_string(&cfg).expect("serialize FR-052 safety config");
let cfg2: SafetyConfig =
serde_json::from_str(&json).expect("deserialize FR-052 safety config");
assert_eq!(cfg2.inflight_wait_timeout_secs, 600);
assert_eq!(cfg2.inflight_heartbeat_grace_secs, 120);
}
#[test]
fn test_fr052_fields_explicit_json_deserialization() {
let json = r#"{"inflight_wait_timeout_secs": 10, "inflight_heartbeat_grace_secs": 30}"#;
let cfg: SafetyConfig =
serde_json::from_str(json).expect("deserialize explicit FR-052 fields");
assert_eq!(cfg.inflight_wait_timeout_secs, 10);
assert_eq!(cfg.inflight_heartbeat_grace_secs, 30);
assert_eq!(cfg.max_consecutive_failures, 3);
}
#[test]
fn test_fr035_fields_serde_round_trip() {
let cfg = SafetyConfig {
max_item_step_failures: 7,
min_cycle_interval_secs: 120,
..SafetyConfig::default()
};
let json = serde_json::to_string(&cfg).expect("serialize FR-035 safety config");
let cfg2: SafetyConfig =
serde_json::from_str(&json).expect("deserialize FR-035 safety config");
assert_eq!(cfg2.max_item_step_failures, 7);
assert_eq!(cfg2.min_cycle_interval_secs, 120);
}
#[test]
fn test_fr035_fields_explicit_json_deserialization() {
let json = r#"{"max_item_step_failures": 5, "min_cycle_interval_secs": 30}"#;
let cfg: SafetyConfig =
serde_json::from_str(json).expect("deserialize explicit FR-035 fields");
assert_eq!(cfg.max_item_step_failures, 5);
assert_eq!(cfg.min_cycle_interval_secs, 30);
assert_eq!(cfg.max_consecutive_failures, 3);
assert!(!cfg.auto_rollback);
}
#[test]
fn test_checkpoint_strategy_default() {
let strat = CheckpointStrategy::default();
assert!(matches!(strat, CheckpointStrategy::None));
}
#[test]
fn test_checkpoint_strategy_serde_round_trip() {
for s in &["\"none\"", "\"git_tag\"", "\"git_stash\""] {
let strat: CheckpointStrategy =
serde_json::from_str(s).expect("deserialize checkpoint strategy");
let json = serde_json::to_string(&strat).expect("serialize checkpoint strategy");
assert_eq!(&json, s);
}
}
}