Skip to main content

brainwires_autonomy/
config.rs

1//! Configuration types for autonomous operations.
2
3use serde::{Deserialize, Serialize};
4
5/// Default dead man's switch heartbeat timeout in seconds (30 minutes).
6const DEFAULT_HEARTBEAT_TIMEOUT_SECS: u64 = 1800;
7
8/// Top-level configuration for the autonomy subsystem.
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct AutonomyConfig {
11    /// Self-improvement session configuration.
12    #[serde(default)]
13    pub self_improve: SelfImprovementConfig,
14    /// Safety and budget limits.
15    #[serde(default)]
16    pub safety: SafetyConfig,
17    /// Git workflow configuration.
18    #[serde(default)]
19    pub git_workflow: GitWorkflowConfig,
20    /// Crash recovery configuration.
21    #[serde(default)]
22    pub crash_recovery: CrashRecoveryConfig,
23    /// GPIO hardware access configuration.
24    #[serde(default)]
25    pub gpio: GpioConfig,
26    /// Scheduled task configuration.
27    #[serde(default)]
28    pub scheduler: SchedulerConfig,
29    /// File system reactor configuration.
30    #[serde(default)]
31    pub reactor: ReactorConfig,
32    /// System service management configuration.
33    #[serde(default)]
34    pub services: ServiceConfig,
35}
36
37/// Configuration for self-improvement sessions.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SelfImprovementConfig {
40    /// Maximum improvement cycles to run.
41    pub max_cycles: u32,
42    /// Maximum total cost in USD.
43    pub max_budget: f64,
44    /// If true, generate tasks but don't execute them.
45    pub dry_run: bool,
46    /// Enabled strategy names (empty = all).
47    pub strategies: Vec<String>,
48    /// Max iterations per agent task.
49    pub agent_iterations: u32,
50    /// Max diff lines per single task.
51    pub max_diff_per_task: u32,
52    /// Max total diff lines across entire session.
53    pub max_total_diff: u32,
54    /// Create PRs for committed changes.
55    pub create_prs: bool,
56    /// Git branch prefix for improvement branches.
57    pub branch_prefix: String,
58    /// Override model for agent tasks.
59    pub model: Option<String>,
60    /// Override provider.
61    pub provider: Option<String>,
62    /// Consecutive failures before circuit breaker trips.
63    pub circuit_breaker_threshold: u32,
64}
65
66impl Default for SelfImprovementConfig {
67    fn default() -> Self {
68        Self {
69            max_cycles: 10,
70            max_budget: 10.0,
71            dry_run: false,
72            strategies: Vec::new(),
73            agent_iterations: 25,
74            max_diff_per_task: 200,
75            max_total_diff: 1000,
76            create_prs: false,
77            branch_prefix: "self-improve/".to_string(),
78            model: None,
79            provider: None,
80            circuit_breaker_threshold: 3,
81        }
82    }
83}
84
85impl SelfImprovementConfig {
86    /// Check if a given strategy name is enabled (empty list = all enabled).
87    pub fn is_strategy_enabled(&self, name: &str) -> bool {
88        self.strategies.is_empty() || self.strategies.iter().any(|s| s == name)
89    }
90}
91
92/// Per-strategy configuration passed to strategy task generators during scanning.
93#[derive(Debug, Clone)]
94pub struct StrategyConfig {
95    /// Path to the repository root.
96    pub repo_path: String,
97    /// Maximum tasks to generate per strategy.
98    pub max_tasks_per_strategy: usize,
99}
100
101impl Default for StrategyConfig {
102    fn default() -> Self {
103        Self {
104            repo_path: ".".to_string(),
105            max_tasks_per_strategy: 5,
106        }
107    }
108}
109
110/// Safety and budget configuration for autonomous operations.
111///
112/// Controls cost limits, operation quotas, circuit breaker behavior, and
113/// file path restrictions that apply across all autonomous features.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct SafetyConfig {
116    /// Maximum total cost in USD across all operations.
117    pub max_total_cost: f64,
118    /// Maximum cost per single operation.
119    pub max_per_operation_cost: f64,
120    /// Maximum daily operations.
121    pub max_daily_operations: u32,
122    /// Consecutive failure threshold for circuit breaker.
123    pub circuit_breaker_threshold: u32,
124    /// Circuit breaker cooldown in seconds.
125    pub circuit_breaker_cooldown_secs: u64,
126    /// Max diff lines per task.
127    pub max_diff_per_task: u32,
128    /// Max total diff lines per session.
129    pub max_total_diff: u32,
130    /// Max concurrent agents.
131    pub max_concurrent_agents: u32,
132    /// Dead man's switch heartbeat timeout in seconds.
133    pub heartbeat_timeout_secs: u64,
134    /// Allowed path globs for file modifications.
135    pub allowed_paths: Vec<String>,
136    /// Forbidden path globs (takes precedence over allowed).
137    pub forbidden_paths: Vec<String>,
138}
139
140impl Default for SafetyConfig {
141    fn default() -> Self {
142        Self {
143            max_total_cost: 50.0,
144            max_per_operation_cost: 5.0,
145            max_daily_operations: 100,
146            circuit_breaker_threshold: 3,
147            circuit_breaker_cooldown_secs: 300,
148            max_diff_per_task: 200,
149            max_total_diff: 1000,
150            max_concurrent_agents: 5,
151            heartbeat_timeout_secs: DEFAULT_HEARTBEAT_TIMEOUT_SECS,
152            allowed_paths: Vec::new(),
153            forbidden_paths: Vec::new(),
154        }
155    }
156}
157
158/// Git workflow pipeline configuration.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct GitWorkflowConfig {
161    /// Branch prefix for autonomous fix branches.
162    pub branch_prefix: String,
163    /// Whether to auto-merge PRs when policy allows.
164    pub auto_merge: bool,
165    /// Default merge method.
166    pub merge_method: String,
167    /// Minimum investigation confidence to proceed with fix.
168    pub min_confidence: f64,
169    /// Webhook server configuration.
170    #[serde(default)]
171    pub webhook: WebhookConfig,
172}
173
174impl Default for GitWorkflowConfig {
175    fn default() -> Self {
176        Self {
177            branch_prefix: "autonomy/".to_string(),
178            auto_merge: false,
179            merge_method: "squash".to_string(),
180            min_confidence: 0.7,
181            webhook: WebhookConfig::default(),
182        }
183    }
184}
185
186/// Webhook server configuration.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct WebhookConfig {
189    /// Listen address.
190    pub listen_addr: String,
191    /// Listen port.
192    pub port: u16,
193    /// Webhook secret for HMAC verification.
194    pub secret: Option<String>,
195    /// Directory for webhook event logs.
196    #[serde(default = "default_webhook_log_dir")]
197    pub log_dir: String,
198    /// Number of days to keep webhook logs.
199    #[serde(default = "default_webhook_keep_days")]
200    pub keep_days: u32,
201    /// Per-repository webhook configuration.
202    #[serde(default)]
203    pub repos: std::collections::HashMap<String, WebhookRepoConfig>,
204}
205
206fn default_webhook_log_dir() -> String {
207    dirs::home_dir()
208        .map(|h| {
209            h.join(".brainwires")
210                .join("webhook-logs")
211                .to_string_lossy()
212                .to_string()
213        })
214        .unwrap_or_else(|| "/tmp/brainwires/webhook-logs".to_string())
215}
216
217fn default_webhook_keep_days() -> u32 {
218    30
219}
220
221impl Default for WebhookConfig {
222    fn default() -> Self {
223        Self {
224            listen_addr: "0.0.0.0".to_string(),
225            port: 3000,
226            secret: None,
227            log_dir: default_webhook_log_dir(),
228            keep_days: default_webhook_keep_days(),
229            repos: std::collections::HashMap::new(),
230        }
231    }
232}
233
234/// Per-repository webhook configuration.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct WebhookRepoConfig {
237    /// Which events to handle (e.g., "issues", "push", "pull_request").
238    #[serde(default)]
239    pub events: Vec<String>,
240    /// Whether to automatically investigate issues.
241    #[serde(default)]
242    pub auto_investigate: bool,
243    /// Whether to automatically apply fixes.
244    #[serde(default)]
245    pub auto_fix: bool,
246    /// Whether to automatically merge PRs when policy allows.
247    #[serde(default)]
248    pub auto_merge: bool,
249    /// Only handle issues with these labels (empty = all).
250    #[serde(default)]
251    pub labels_filter: Vec<String>,
252    /// Commands to run after processing an event.
253    #[serde(default)]
254    pub post_commands: Vec<CommandConfig>,
255}
256
257impl Default for WebhookRepoConfig {
258    fn default() -> Self {
259        Self {
260            events: vec!["issues".to_string()],
261            auto_investigate: true,
262            auto_fix: false,
263            auto_merge: false,
264            labels_filter: Vec::new(),
265            post_commands: Vec::new(),
266        }
267    }
268}
269
270/// Command to execute with variable interpolation support.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct CommandConfig {
273    /// Command to run.
274    pub cmd: String,
275    /// Arguments for the command.
276    #[serde(default)]
277    pub args: Vec<String>,
278    /// Working directory (supports variables like `${REPO_NAME}`).
279    #[serde(default)]
280    pub working_dir: Option<String>,
281}
282
283/// Crash recovery configuration.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct CrashRecoveryConfig {
286    /// Maximum fix attempts before giving up.
287    pub max_fix_attempts: u32,
288    /// Path to crash recovery state file.
289    pub state_file: String,
290    /// Whether crash recovery is enabled.
291    pub enabled: bool,
292}
293
294impl Default for CrashRecoveryConfig {
295    fn default() -> Self {
296        Self {
297            max_fix_attempts: 3,
298            state_file: dirs::home_dir()
299                .map(|h| {
300                    h.join(".brainwires")
301                        .join("crash-recovery.json")
302                        .to_string_lossy()
303                        .to_string()
304                })
305                .unwrap_or_else(|| "/tmp/brainwires/crash-recovery.json".to_string()),
306            enabled: true,
307        }
308    }
309}
310
311/// GPIO hardware access configuration.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct GpioConfig {
314    /// Allowed (chip, line) pairs — empty means no access.
315    #[serde(default)]
316    pub allowed_pins: Vec<(u32, u32)>,
317    /// Maximum concurrent pins an agent may hold.
318    pub max_concurrent_pins: usize,
319    /// Timeout in seconds before auto-releasing a pin from an unhealthy agent.
320    pub auto_release_timeout_secs: u64,
321}
322
323impl Default for GpioConfig {
324    fn default() -> Self {
325        Self {
326            allowed_pins: Vec::new(),
327            max_concurrent_pins: 4,
328            auto_release_timeout_secs: 300,
329        }
330    }
331}
332
333/// Scheduled task configuration.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct SchedulerConfig {
336    /// Maximum concurrent scheduled tasks.
337    pub max_concurrent_tasks: u32,
338    /// Scheduled task definitions.
339    #[serde(default)]
340    pub tasks: Vec<ScheduledTaskDef>,
341}
342
343impl Default for SchedulerConfig {
344    fn default() -> Self {
345        Self {
346            max_concurrent_tasks: 3,
347            tasks: Vec::new(),
348        }
349    }
350}
351
352/// Definition of a scheduled task in configuration.
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct ScheduledTaskDef {
355    /// Unique task identifier.
356    pub id: String,
357    /// Human-readable name.
358    pub name: String,
359    /// Cron expression (e.g., "0 */6 * * *" for every 6 hours).
360    pub cron_expression: String,
361    /// Type of task to run.
362    pub task_type: String,
363    /// Whether this task is enabled.
364    #[serde(default = "default_true")]
365    pub enabled: bool,
366    /// Maximum runtime in seconds before the task is killed.
367    #[serde(default = "default_max_runtime")]
368    pub max_runtime_secs: u64,
369}
370
371fn default_true() -> bool {
372    true
373}
374
375fn default_max_runtime() -> u64 {
376    3600
377}
378
379/// File system reactor configuration.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct ReactorConfig {
382    /// Maximum events per minute before rate limiting kicks in.
383    pub max_events_per_minute: u32,
384    /// Global debounce window in milliseconds.
385    pub global_debounce_ms: u64,
386    /// Maximum recursive watch depth.
387    pub max_watch_depth: u32,
388    /// Reactor rules.
389    #[serde(default)]
390    pub rules: Vec<ReactorRuleDef>,
391}
392
393impl Default for ReactorConfig {
394    fn default() -> Self {
395        Self {
396            max_events_per_minute: 60,
397            global_debounce_ms: 500,
398            max_watch_depth: 10,
399            rules: Vec::new(),
400        }
401    }
402}
403
404/// Definition of a reactor rule in configuration.
405#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct ReactorRuleDef {
407    /// Unique rule identifier.
408    pub id: String,
409    /// Human-readable name.
410    pub name: String,
411    /// Paths to watch.
412    pub watch_paths: Vec<String>,
413    /// Glob patterns for file matching.
414    #[serde(default)]
415    pub patterns: Vec<String>,
416    /// Patterns to exclude.
417    #[serde(default)]
418    pub exclude_patterns: Vec<String>,
419    /// File system event types to react to.
420    #[serde(default)]
421    pub event_types: Vec<String>,
422    /// Per-rule debounce in milliseconds.
423    #[serde(default = "default_debounce")]
424    pub debounce_ms: u64,
425    /// Whether this rule is enabled.
426    #[serde(default = "default_true")]
427    pub enabled: bool,
428}
429
430fn default_debounce() -> u64 {
431    1000
432}
433
434/// System service management configuration.
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct ServiceConfig {
437    /// Explicit allow-list of service names (no wildcards).
438    #[serde(default)]
439    pub allowed_services: Vec<String>,
440    /// Additional forbidden services (supplements hardcoded deny-list).
441    #[serde(default)]
442    pub forbidden_services: Vec<String>,
443    /// If true, only status/list/logs are permitted (no start/stop/restart).
444    #[serde(default = "default_true")]
445    pub read_only: bool,
446    /// Docker socket path override.
447    #[serde(default)]
448    pub docker_socket_path: Option<String>,
449}
450
451impl Default for ServiceConfig {
452    fn default() -> Self {
453        Self {
454            allowed_services: Vec::new(),
455            forbidden_services: Vec::new(),
456            read_only: true,
457            docker_socket_path: None,
458        }
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    #[test]
467    fn autonomy_config_default_succeeds() {
468        let config = AutonomyConfig::default();
469        // Verify field types are accessible
470        let _si: &SelfImprovementConfig = &config.self_improve;
471        let _safety: &SafetyConfig = &config.safety;
472        let _git: &GitWorkflowConfig = &config.git_workflow;
473    }
474
475    #[test]
476    fn self_improvement_config_default_has_sensible_values() {
477        let config = SelfImprovementConfig::default();
478        assert_eq!(config.max_cycles, 10);
479        assert!((config.max_budget - 10.0).abs() < f64::EPSILON);
480        assert!(!config.dry_run);
481        assert!(config.strategies.is_empty());
482        assert_eq!(config.agent_iterations, 25);
483        assert_eq!(config.circuit_breaker_threshold, 3);
484        assert_eq!(config.branch_prefix, "self-improve/");
485        assert!(config.model.is_none());
486        assert!(config.provider.is_none());
487    }
488
489    #[test]
490    fn safety_config_default_has_sensible_values() {
491        let config = SafetyConfig::default();
492        assert!((config.max_total_cost - 50.0).abs() < f64::EPSILON);
493        assert!((config.max_per_operation_cost - 5.0).abs() < f64::EPSILON);
494        assert_eq!(config.max_daily_operations, 100);
495        assert_eq!(config.circuit_breaker_threshold, 3);
496        assert_eq!(config.circuit_breaker_cooldown_secs, 300);
497        assert_eq!(config.max_concurrent_agents, 5);
498        assert_eq!(config.heartbeat_timeout_secs, 1800);
499        assert!(config.allowed_paths.is_empty());
500        assert!(config.forbidden_paths.is_empty());
501    }
502
503    #[test]
504    fn git_workflow_config_default_has_sensible_values() {
505        let config = GitWorkflowConfig::default();
506        assert_eq!(config.branch_prefix, "autonomy/");
507        assert!(!config.auto_merge);
508        assert_eq!(config.merge_method, "squash");
509        assert!((config.min_confidence - 0.7).abs() < f64::EPSILON);
510        assert_eq!(config.webhook.port, 3000);
511    }
512
513    #[test]
514    fn serde_roundtrip_autonomy_config() {
515        let config = AutonomyConfig::default();
516        let json = serde_json::to_string(&config).expect("serialize");
517        let deserialized: AutonomyConfig = serde_json::from_str(&json).expect("deserialize");
518        assert_eq!(
519            deserialized.self_improve.max_cycles,
520            config.self_improve.max_cycles
521        );
522        assert_eq!(
523            deserialized.safety.max_total_cost,
524            config.safety.max_total_cost
525        );
526        assert_eq!(
527            deserialized.git_workflow.branch_prefix,
528            config.git_workflow.branch_prefix
529        );
530    }
531
532    #[test]
533    fn is_strategy_enabled_empty_list_enables_all() {
534        let config = SelfImprovementConfig::default();
535        assert!(config.is_strategy_enabled("clippy"));
536        assert!(config.is_strategy_enabled("anything"));
537    }
538
539    #[test]
540    fn is_strategy_enabled_specific_list() {
541        let config = SelfImprovementConfig {
542            strategies: vec!["clippy".to_string(), "todo".to_string()],
543            ..Default::default()
544        };
545        assert!(config.is_strategy_enabled("clippy"));
546        assert!(config.is_strategy_enabled("todo"));
547        assert!(!config.is_strategy_enabled("dead_code"));
548    }
549}