Skip to main content

orchestrator_config/config/
safety.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4use super::{
5    AgentConfig, EnvStoreConfig, ExecutionProfileConfig, HealthPolicyConfig, InvariantConfig,
6    SecretStoreConfig, StepTemplateConfig, TriggerConfig, WorkflowConfig,
7};
8
9/// Safety configuration for self-bootstrap and dangerous operations
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct SafetyConfig {
12    /// Maximum consecutive failures before auto-rollback
13    #[serde(default = "default_max_consecutive_failures")]
14    pub max_consecutive_failures: u32,
15    /// Automatically rollback on repeated failures
16    #[serde(default)]
17    pub auto_rollback: bool,
18    /// Strategy for creating checkpoints
19    #[serde(default)]
20    pub checkpoint_strategy: CheckpointStrategy,
21    /// Per-step timeout in seconds (default: 1800 = 30 min)
22    #[serde(default)]
23    pub step_timeout_secs: Option<u64>,
24    /// Snapshot the release binary at cycle start for rollback
25    #[serde(default)]
26    pub binary_snapshot: bool,
27    /// Safety policy profile for self-referential workflows
28    #[serde(default)]
29    pub profile: WorkflowSafetyProfile,
30    /// WP04: Invariant constraints enforced by the engine
31    #[serde(default)]
32    pub invariants: Vec<InvariantConfig>,
33    /// WP02: Maximum total spawned tasks per parent
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub max_spawned_tasks: Option<usize>,
36    /// WP02: Maximum spawn depth (parent → child → grandchild)
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub max_spawn_depth: Option<usize>,
39    /// WP02: Minimum seconds between spawn bursts
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub spawn_cooldown_seconds: Option<u64>,
42    /// FR-035: Per-item per-step consecutive failure threshold before blocking
43    #[serde(default = "default_max_item_step_failures")]
44    pub max_item_step_failures: u32,
45    /// FR-035: Minimum cycle interval in seconds; rapid cycles below this trigger pause
46    #[serde(default = "default_min_cycle_interval_secs")]
47    pub min_cycle_interval_secs: u64,
48    /// Stall auto-kill threshold in seconds. When a step produces less than
49    /// `LOW_OUTPUT_DELTA_THRESHOLD_BYTES` per heartbeat for this duration, the
50    /// step is killed with exit_code=-7. Default (None) uses the built-in 900s.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub stall_timeout_secs: Option<u64>,
53    /// FR-052: Maximum seconds to wait for in-flight runs when no heartbeat activity
54    #[serde(default = "default_inflight_wait_timeout_secs")]
55    pub inflight_wait_timeout_secs: u64,
56    /// FR-052: Heartbeat must be within this many seconds to be considered active
57    #[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/// Checkpoint strategy for rollback support
104#[derive(Debug, Clone, Serialize, Deserialize, Default)]
105#[serde(rename_all = "snake_case")]
106pub enum CheckpointStrategy {
107    #[default]
108    /// Disable checkpoint creation.
109    None,
110    /// Create Git tags for rollback checkpoints.
111    GitTag,
112    /// Create Git stash entries for rollback checkpoints.
113    GitStash,
114}
115
116/// Safety profile for self-referential workflows.
117#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
118#[serde(rename_all = "snake_case")]
119pub enum WorkflowSafetyProfile {
120    #[default]
121    /// Standard safety behavior for ordinary workflows.
122    Standard,
123    /// Extra guardrails for self-referential workflows targeting the orchestrator source tree.
124    SelfReferentialProbe,
125}
126
127/// Workspace configuration
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct WorkspaceConfig {
130    /// Path to the workspace root, relative to the application root unless absolute.
131    pub root_path: String,
132    /// QA target paths evaluated for this workspace.
133    pub qa_targets: Vec<String>,
134    /// Directory used to store QA tickets for the workspace.
135    pub ticket_dir: String,
136    /// When true, the workspace points to the orchestrator's own source tree
137    #[serde(default)]
138    pub self_referential: bool,
139    /// Default health policy for agents operating in this workspace.
140    #[serde(default, skip_serializing_if = "HealthPolicyConfig::is_default")]
141    pub health_policy: HealthPolicyConfig,
142}
143
144/// Project-level configuration
145#[derive(Debug, Clone, Serialize, Deserialize, Default)]
146pub struct ProjectConfig {
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    /// Optional human-readable description of the project.
149    pub description: Option<String>,
150    #[serde(default)]
151    /// Workspaces available within the project.
152    pub workspaces: HashMap<String, WorkspaceConfig>,
153    #[serde(default)]
154    /// Agents available within the project.
155    pub agents: HashMap<String, AgentConfig>,
156    #[serde(default)]
157    /// Workflows available within the project.
158    pub workflows: HashMap<String, WorkflowConfig>,
159    #[serde(default)]
160    /// Named step templates reusable by workflows in the project.
161    pub step_templates: HashMap<String, StepTemplateConfig>,
162    #[serde(default)]
163    /// Environment stores scoped to the project.
164    pub env_stores: HashMap<String, EnvStoreConfig>,
165    #[serde(default)]
166    /// Secret stores scoped to the project. All values are sensitive.
167    pub secret_stores: HashMap<String, SecretStoreConfig>,
168    #[serde(default)]
169    /// Execution profiles available within the project.
170    pub execution_profiles: HashMap<String, ExecutionProfileConfig>,
171    #[serde(default)]
172    /// Trigger definitions scoped to the project.
173    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        // Other fields should remain at their defaults.
278        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}