orchestrator_config/config/
safety.rs1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4use super::{
5 AgentConfig, EnvStoreConfig, ExecutionProfileConfig, HealthPolicyConfig, InvariantConfig,
6 SecretStoreConfig, StepTemplateConfig, TriggerConfig, WorkflowConfig,
7};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct SafetyConfig {
12 #[serde(default = "default_max_consecutive_failures")]
14 pub max_consecutive_failures: u32,
15 #[serde(default)]
17 pub auto_rollback: bool,
18 #[serde(default)]
20 pub checkpoint_strategy: CheckpointStrategy,
21 #[serde(default)]
23 pub step_timeout_secs: Option<u64>,
24 #[serde(default)]
26 pub binary_snapshot: bool,
27 #[serde(default)]
29 pub profile: WorkflowSafetyProfile,
30 #[serde(default)]
32 pub invariants: Vec<InvariantConfig>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub max_spawned_tasks: Option<usize>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub max_spawn_depth: Option<usize>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub spawn_cooldown_seconds: Option<u64>,
42 #[serde(default = "default_max_item_step_failures")]
44 pub max_item_step_failures: u32,
45 #[serde(default = "default_min_cycle_interval_secs")]
47 pub min_cycle_interval_secs: u64,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub stall_timeout_secs: Option<u64>,
53 #[serde(default = "default_inflight_wait_timeout_secs")]
55 pub inflight_wait_timeout_secs: u64,
56 #[serde(default = "default_inflight_heartbeat_grace_secs")]
58 pub inflight_heartbeat_grace_secs: u64,
59}
60
61fn default_max_consecutive_failures() -> u32 {
62 3
63}
64
65fn default_max_item_step_failures() -> u32 {
66 3
67}
68
69fn default_min_cycle_interval_secs() -> u64 {
70 60
71}
72
73fn default_inflight_wait_timeout_secs() -> u64 {
74 300
75}
76
77fn default_inflight_heartbeat_grace_secs() -> u64 {
78 60
79}
80
81impl Default for SafetyConfig {
82 fn default() -> Self {
83 Self {
84 max_consecutive_failures: 3,
85 auto_rollback: false,
86 checkpoint_strategy: CheckpointStrategy::default(),
87 step_timeout_secs: None,
88 binary_snapshot: false,
89 profile: WorkflowSafetyProfile::default(),
90 invariants: Vec::new(),
91 max_spawned_tasks: None,
92 max_spawn_depth: None,
93 spawn_cooldown_seconds: None,
94 stall_timeout_secs: None,
95 max_item_step_failures: 3,
96 min_cycle_interval_secs: 60,
97 inflight_wait_timeout_secs: 300,
98 inflight_heartbeat_grace_secs: 60,
99 }
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, Default)]
105#[serde(rename_all = "snake_case")]
106pub enum CheckpointStrategy {
107 #[default]
108 None,
110 GitTag,
112 GitStash,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
118#[serde(rename_all = "snake_case")]
119pub enum WorkflowSafetyProfile {
120 #[default]
121 Standard,
123 SelfReferentialProbe,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct WorkspaceConfig {
130 pub root_path: String,
132 pub qa_targets: Vec<String>,
134 pub ticket_dir: String,
136 #[serde(default)]
138 pub self_referential: bool,
139 #[serde(default, skip_serializing_if = "HealthPolicyConfig::is_default")]
141 pub health_policy: HealthPolicyConfig,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, Default)]
146pub struct ProjectConfig {
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub description: Option<String>,
150 #[serde(default)]
151 pub workspaces: HashMap<String, WorkspaceConfig>,
153 #[serde(default)]
154 pub agents: HashMap<String, AgentConfig>,
156 #[serde(default)]
157 pub workflows: HashMap<String, WorkflowConfig>,
159 #[serde(default)]
160 pub step_templates: HashMap<String, StepTemplateConfig>,
162 #[serde(default)]
163 pub env_stores: HashMap<String, EnvStoreConfig>,
165 #[serde(default)]
166 pub secret_stores: HashMap<String, SecretStoreConfig>,
168 #[serde(default)]
169 pub execution_profiles: HashMap<String, ExecutionProfileConfig>,
171 #[serde(default)]
172 pub triggers: HashMap<String, TriggerConfig>,
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn test_safety_config_default() {
182 let cfg = SafetyConfig::default();
183 assert_eq!(cfg.max_consecutive_failures, 3);
184 assert!(!cfg.auto_rollback);
185 assert!(matches!(cfg.checkpoint_strategy, CheckpointStrategy::None));
186 assert!(cfg.step_timeout_secs.is_none());
187 assert!(cfg.stall_timeout_secs.is_none());
188 assert!(!cfg.binary_snapshot);
189 assert_eq!(cfg.max_item_step_failures, 3);
190 assert_eq!(cfg.min_cycle_interval_secs, 60);
191 assert_eq!(cfg.inflight_wait_timeout_secs, 300);
192 assert_eq!(cfg.inflight_heartbeat_grace_secs, 60);
193 }
194
195 #[test]
196 fn test_safety_config_serde_round_trip() {
197 let cfg = SafetyConfig {
198 max_consecutive_failures: 5,
199 auto_rollback: true,
200 checkpoint_strategy: CheckpointStrategy::GitTag,
201 step_timeout_secs: Some(600),
202 binary_snapshot: true,
203 profile: WorkflowSafetyProfile::SelfReferentialProbe,
204 ..SafetyConfig::default()
205 };
206 let json = serde_json::to_string(&cfg).expect("serialize safety config");
207 let cfg2: SafetyConfig = serde_json::from_str(&json).expect("deserialize safety config");
208 assert_eq!(cfg2.max_consecutive_failures, 5);
209 assert!(cfg2.auto_rollback);
210 assert!(matches!(
211 cfg2.checkpoint_strategy,
212 CheckpointStrategy::GitTag
213 ));
214 assert_eq!(cfg2.step_timeout_secs, Some(600));
215 assert!(cfg2.binary_snapshot);
216 assert_eq!(cfg2.profile, WorkflowSafetyProfile::SelfReferentialProbe);
217 }
218
219 #[test]
220 fn test_safety_config_deserialize_minimal() {
221 let json = r#"{}"#;
222 let cfg: SafetyConfig = serde_json::from_str(json).expect("deserialize minimal safety");
223 assert_eq!(cfg.max_consecutive_failures, 3);
224 assert!(!cfg.auto_rollback);
225 assert_eq!(cfg.profile, WorkflowSafetyProfile::Standard);
226 assert_eq!(cfg.max_item_step_failures, 3);
227 assert_eq!(cfg.min_cycle_interval_secs, 60);
228 assert_eq!(cfg.inflight_wait_timeout_secs, 300);
229 assert_eq!(cfg.inflight_heartbeat_grace_secs, 60);
230 }
231
232 #[test]
233 fn test_fr052_fields_serde_round_trip() {
234 let cfg = SafetyConfig {
235 inflight_wait_timeout_secs: 600,
236 inflight_heartbeat_grace_secs: 120,
237 ..SafetyConfig::default()
238 };
239 let json = serde_json::to_string(&cfg).expect("serialize FR-052 safety config");
240 let cfg2: SafetyConfig =
241 serde_json::from_str(&json).expect("deserialize FR-052 safety config");
242 assert_eq!(cfg2.inflight_wait_timeout_secs, 600);
243 assert_eq!(cfg2.inflight_heartbeat_grace_secs, 120);
244 }
245
246 #[test]
247 fn test_fr052_fields_explicit_json_deserialization() {
248 let json = r#"{"inflight_wait_timeout_secs": 10, "inflight_heartbeat_grace_secs": 30}"#;
249 let cfg: SafetyConfig =
250 serde_json::from_str(json).expect("deserialize explicit FR-052 fields");
251 assert_eq!(cfg.inflight_wait_timeout_secs, 10);
252 assert_eq!(cfg.inflight_heartbeat_grace_secs, 30);
253 assert_eq!(cfg.max_consecutive_failures, 3);
254 }
255
256 #[test]
257 fn test_fr035_fields_serde_round_trip() {
258 let cfg = SafetyConfig {
259 max_item_step_failures: 7,
260 min_cycle_interval_secs: 120,
261 ..SafetyConfig::default()
262 };
263 let json = serde_json::to_string(&cfg).expect("serialize FR-035 safety config");
264 let cfg2: SafetyConfig =
265 serde_json::from_str(&json).expect("deserialize FR-035 safety config");
266 assert_eq!(cfg2.max_item_step_failures, 7);
267 assert_eq!(cfg2.min_cycle_interval_secs, 120);
268 }
269
270 #[test]
271 fn test_fr035_fields_explicit_json_deserialization() {
272 let json = r#"{"max_item_step_failures": 5, "min_cycle_interval_secs": 30}"#;
273 let cfg: SafetyConfig =
274 serde_json::from_str(json).expect("deserialize explicit FR-035 fields");
275 assert_eq!(cfg.max_item_step_failures, 5);
276 assert_eq!(cfg.min_cycle_interval_secs, 30);
277 assert_eq!(cfg.max_consecutive_failures, 3);
279 assert!(!cfg.auto_rollback);
280 }
281
282 #[test]
283 fn test_checkpoint_strategy_default() {
284 let strat = CheckpointStrategy::default();
285 assert!(matches!(strat, CheckpointStrategy::None));
286 }
287
288 #[test]
289 fn test_checkpoint_strategy_serde_round_trip() {
290 for s in &["\"none\"", "\"git_tag\"", "\"git_stash\""] {
291 let strat: CheckpointStrategy =
292 serde_json::from_str(s).expect("deserialize checkpoint strategy");
293 let json = serde_json::to_string(&strat).expect("serialize checkpoint strategy");
294 assert_eq!(&json, s);
295 }
296 }
297}