Skip to main content

batty_cli/team/config/
types.rs

1//! Type definitions for team configuration.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Deserializer};
6
7use super::super::DEFAULT_EVENT_LOG_MAX_BYTES;
8
9#[derive(Debug, Clone)]
10pub struct TeamConfig {
11    pub name: String,
12    /// Team-level default agent backend. Individual roles can override this
13    /// with their own `agent` field. Resolution order:
14    /// role-level agent > team-level agent > "claude" (hardcoded default).
15    pub agent: Option<String>,
16    pub workflow_mode: WorkflowMode,
17    pub board: BoardConfig,
18    pub standup: StandupConfig,
19    pub automation: AutomationConfig,
20    pub automation_sender: Option<String>,
21    /// External senders (e.g. email-router, slack-bridge) that are allowed to
22    /// message any role even though they are not team members.
23    pub external_senders: Vec<String>,
24    pub orchestrator_pane: bool,
25    pub orchestrator_position: OrchestratorPosition,
26    pub layout: Option<LayoutConfig>,
27    pub workflow_policy: WorkflowPolicy,
28    pub cost: CostConfig,
29    pub grafana: GrafanaConfig,
30    /// When true, agents are spawned as shim subprocesses instead of
31    /// directly in tmux panes. The shim manages PTY, state classification,
32    /// and message delivery over a structured channel.
33    pub use_shim: bool,
34    /// When true and `use_shim` is enabled, agents that support structured I/O
35    /// (Claude Code, Codex) communicate via JSON protocols instead of PTY
36    /// screen-scraping. Requires `use_shim: true`. Defaults to true.
37    pub use_sdk_mode: bool,
38    /// When true and `use_shim` is enabled, crashed agents are automatically
39    /// respawned instead of escalating to the manager. This is the default
40    /// posture for unattended teams; disable it only for debugging or
41    /// deliberate manual supervision.
42    pub auto_respawn_on_crash: bool,
43    /// Interval in seconds between Ping health checks sent to shim handles.
44    pub shim_health_check_interval_secs: u64,
45    /// Seconds without a Pong response before a shim handle is considered stale.
46    pub shim_health_timeout_secs: u64,
47    /// Seconds to wait for graceful shutdown before sending Kill.
48    pub shim_shutdown_timeout_secs: u32,
49    /// Maximum seconds an agent can remain in "Working" state before being
50    /// force-transitioned to Idle. Prevents permanent stalls where the shim
51    /// state classifier gets stuck on "working" while the agent is actually
52    /// idle. 0 or None disables the check. Default: 1800 (30 minutes).
53    pub shim_working_state_timeout_secs: u64,
54    /// Maximum seconds a message can sit in the pending delivery queue before
55    /// being force-delivered via inbox fallback. Prevents message loss when
56    /// the target agent appears permanently busy. Default: 600 (10 minutes).
57    pub pending_queue_max_age_secs: u64,
58    pub event_log_max_bytes: u64,
59    pub retro_min_duration_secs: u64,
60    pub roles: Vec<RoleDef>,
61}
62
63#[derive(Debug, Deserialize)]
64struct TeamConfigWire {
65    pub name: String,
66    #[serde(default)]
67    pub agent: Option<String>,
68    #[serde(default)]
69    pub workflow_mode: Option<WorkflowMode>,
70    #[serde(default)]
71    pub board: BoardConfig,
72    #[serde(default)]
73    pub standup: StandupConfig,
74    #[serde(default)]
75    pub automation: AutomationConfig,
76    #[serde(default)]
77    pub automation_sender: Option<String>,
78    #[serde(default)]
79    pub external_senders: Vec<String>,
80    #[serde(default)]
81    pub orchestrator_pane: Option<bool>,
82    #[serde(default)]
83    pub orchestrator_position: OrchestratorPosition,
84    #[serde(default)]
85    pub layout: Option<LayoutConfig>,
86    #[serde(default)]
87    pub workflow_policy: WorkflowPolicy,
88    #[serde(default)]
89    pub cost: CostConfig,
90    #[serde(default)]
91    pub grafana: GrafanaConfig,
92    #[serde(default)]
93    pub use_shim: bool,
94    #[serde(default = "default_use_sdk_mode")]
95    pub use_sdk_mode: bool,
96    #[serde(default = "default_auto_respawn_on_crash")]
97    pub auto_respawn_on_crash: bool,
98    #[serde(default = "default_shim_health_check_interval_secs")]
99    pub shim_health_check_interval_secs: u64,
100    #[serde(default = "default_shim_health_timeout_secs")]
101    pub shim_health_timeout_secs: u64,
102    #[serde(default = "default_shim_shutdown_timeout_secs")]
103    pub shim_shutdown_timeout_secs: u32,
104    #[serde(default = "default_shim_working_state_timeout_secs")]
105    pub shim_working_state_timeout_secs: u64,
106    #[serde(default = "default_pending_queue_max_age_secs")]
107    pub pending_queue_max_age_secs: u64,
108    #[serde(default = "default_event_log_max_bytes")]
109    pub event_log_max_bytes: u64,
110    #[serde(default = "default_retro_min_duration_secs")]
111    pub retro_min_duration_secs: u64,
112    pub roles: Vec<RoleDef>,
113}
114
115impl From<TeamConfigWire> for TeamConfig {
116    fn from(wire: TeamConfigWire) -> Self {
117        let orchestrator_pane = wire
118            .orchestrator_pane
119            .unwrap_or_else(default_orchestrator_pane);
120        let workflow_mode = wire.workflow_mode.unwrap_or_else(|| {
121            if matches!(wire.orchestrator_pane, Some(true)) {
122                WorkflowMode::Hybrid
123            } else {
124                default_workflow_mode()
125            }
126        });
127
128        Self {
129            name: wire.name,
130            agent: wire.agent,
131            workflow_mode,
132            board: wire.board,
133            standup: wire.standup,
134            automation: wire.automation,
135            automation_sender: wire.automation_sender,
136            external_senders: wire.external_senders,
137            orchestrator_pane,
138            orchestrator_position: wire.orchestrator_position,
139            layout: wire.layout,
140            workflow_policy: wire.workflow_policy,
141            cost: wire.cost,
142            grafana: wire.grafana,
143            use_shim: wire.use_shim,
144            use_sdk_mode: wire.use_sdk_mode,
145            auto_respawn_on_crash: wire.auto_respawn_on_crash,
146            shim_health_check_interval_secs: wire.shim_health_check_interval_secs,
147            shim_health_timeout_secs: wire.shim_health_timeout_secs,
148            shim_shutdown_timeout_secs: wire.shim_shutdown_timeout_secs,
149            shim_working_state_timeout_secs: wire.shim_working_state_timeout_secs,
150            pending_queue_max_age_secs: wire.pending_queue_max_age_secs,
151            event_log_max_bytes: wire.event_log_max_bytes,
152            retro_min_duration_secs: wire.retro_min_duration_secs,
153            roles: wire.roles,
154        }
155    }
156}
157
158impl<'de> Deserialize<'de> for TeamConfig {
159    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
160    where
161        D: Deserializer<'de>,
162    {
163        TeamConfigWire::deserialize(deserializer).map(Into::into)
164    }
165}
166
167#[derive(Debug, Clone, Deserialize)]
168pub struct GrafanaConfig {
169    #[serde(default)]
170    pub enabled: bool,
171    #[serde(default = "default_grafana_port")]
172    pub port: u16,
173}
174
175impl Default for GrafanaConfig {
176    fn default() -> Self {
177        Self {
178            enabled: false,
179            port: default_grafana_port(),
180        }
181    }
182}
183
184fn default_grafana_port() -> u16 {
185    3000
186}
187
188fn default_use_sdk_mode() -> bool {
189    true
190}
191
192fn default_auto_respawn_on_crash() -> bool {
193    true
194}
195
196#[derive(Debug, Clone, Deserialize, Default)]
197pub struct CostConfig {
198    #[serde(default)]
199    pub models: HashMap<String, ModelPricing>,
200}
201
202#[derive(Debug, Clone, Deserialize)]
203pub struct ModelPricing {
204    pub input_usd_per_mtok: f64,
205    #[serde(default)]
206    pub cached_input_usd_per_mtok: f64,
207    #[serde(default)]
208    pub cache_creation_input_usd_per_mtok: Option<f64>,
209    #[serde(default)]
210    pub cache_creation_5m_input_usd_per_mtok: Option<f64>,
211    #[serde(default)]
212    pub cache_creation_1h_input_usd_per_mtok: Option<f64>,
213    #[serde(default)]
214    pub cache_read_input_usd_per_mtok: f64,
215    pub output_usd_per_mtok: f64,
216    #[serde(default)]
217    pub reasoning_output_usd_per_mtok: Option<f64>,
218}
219
220#[derive(Debug, Clone, Deserialize)]
221pub struct WorkflowPolicy {
222    #[serde(default)]
223    pub wip_limit_per_engineer: Option<u32>,
224    #[serde(default)]
225    pub wip_limit_per_reviewer: Option<u32>,
226    #[serde(default = "default_pipeline_starvation_threshold")]
227    pub pipeline_starvation_threshold: Option<usize>,
228    #[serde(default = "default_escalation_threshold_secs")]
229    pub escalation_threshold_secs: u64,
230    #[serde(default = "default_review_nudge_threshold_secs")]
231    pub review_nudge_threshold_secs: u64,
232    #[serde(default = "default_review_timeout_secs")]
233    pub review_timeout_secs: u64,
234    #[serde(default)]
235    pub review_timeout_overrides: HashMap<String, ReviewTimeoutOverride>,
236    #[serde(default)]
237    pub auto_archive_done_after_secs: Option<u64>,
238    #[serde(default)]
239    pub capability_overrides: HashMap<String, Vec<String>>,
240    #[serde(default = "default_stall_threshold_secs")]
241    pub stall_threshold_secs: u64,
242    #[serde(default = "default_max_stall_restarts")]
243    pub max_stall_restarts: u32,
244    #[serde(default = "default_health_check_interval_secs")]
245    pub health_check_interval_secs: u64,
246    #[serde(default = "default_planning_cycle_cooldown_secs")]
247    pub planning_cycle_cooldown_secs: u64,
248    #[serde(default = "default_narration_detection_threshold")]
249    pub narration_detection_threshold: usize,
250    #[serde(default = "default_context_pressure_threshold_bytes")]
251    pub context_pressure_threshold_bytes: u64,
252    #[serde(default = "default_context_pressure_restart_delay_secs")]
253    pub context_pressure_restart_delay_secs: u64,
254    #[serde(default = "default_uncommitted_warn_threshold")]
255    pub uncommitted_warn_threshold: usize,
256    #[serde(default)]
257    pub test_command: Option<String>,
258    #[serde(default)]
259    pub auto_merge: AutoMergePolicy,
260}
261
262/// Per-priority override for review timeout thresholds.
263/// When a task's priority matches a key in `review_timeout_overrides`,
264/// these values replace the global defaults.
265#[derive(Debug, Clone, Deserialize)]
266pub struct ReviewTimeoutOverride {
267    /// Nudge threshold override (seconds). Falls back to global if absent.
268    pub review_nudge_threshold_secs: Option<u64>,
269    /// Escalation threshold override (seconds). Falls back to global if absent.
270    pub review_timeout_secs: Option<u64>,
271}
272
273impl Default for WorkflowPolicy {
274    fn default() -> Self {
275        Self {
276            wip_limit_per_engineer: None,
277            wip_limit_per_reviewer: None,
278            pipeline_starvation_threshold: default_pipeline_starvation_threshold(),
279            escalation_threshold_secs: default_escalation_threshold_secs(),
280            review_nudge_threshold_secs: default_review_nudge_threshold_secs(),
281            review_timeout_secs: default_review_timeout_secs(),
282            review_timeout_overrides: HashMap::new(),
283            auto_archive_done_after_secs: None,
284            capability_overrides: HashMap::new(),
285            stall_threshold_secs: default_stall_threshold_secs(),
286            max_stall_restarts: default_max_stall_restarts(),
287            health_check_interval_secs: default_health_check_interval_secs(),
288            planning_cycle_cooldown_secs: default_planning_cycle_cooldown_secs(),
289            narration_detection_threshold: default_narration_detection_threshold(),
290            context_pressure_threshold_bytes: default_context_pressure_threshold_bytes(),
291            context_pressure_restart_delay_secs: default_context_pressure_restart_delay_secs(),
292            uncommitted_warn_threshold: default_uncommitted_warn_threshold(),
293            test_command: None,
294            auto_merge: AutoMergePolicy::default(),
295        }
296    }
297}
298
299fn default_planning_cycle_cooldown_secs() -> u64 {
300    300
301}
302
303fn default_context_pressure_threshold_bytes() -> u64 {
304    512_000
305}
306
307fn default_context_pressure_restart_delay_secs() -> u64 {
308    120
309}
310
311fn default_sensitive_paths() -> Vec<String> {
312    vec![
313        "Cargo.toml".to_string(),
314        "team.yaml".to_string(),
315        ".env".to_string(),
316    ]
317}
318
319#[derive(Debug, Clone, Deserialize)]
320pub struct AutoMergePolicy {
321    #[serde(default)]
322    pub enabled: bool,
323    #[serde(default = "default_max_diff_lines")]
324    pub max_diff_lines: usize,
325    #[serde(default = "default_max_files_changed")]
326    pub max_files_changed: usize,
327    #[serde(default = "default_max_modules_touched")]
328    pub max_modules_touched: usize,
329    #[serde(default = "default_sensitive_paths")]
330    pub sensitive_paths: Vec<String>,
331    #[serde(default = "default_confidence_threshold")]
332    pub confidence_threshold: f64,
333    #[serde(default = "default_require_tests_pass")]
334    pub require_tests_pass: bool,
335}
336
337fn default_max_diff_lines() -> usize {
338    200
339}
340fn default_max_files_changed() -> usize {
341    5
342}
343fn default_max_modules_touched() -> usize {
344    2
345}
346fn default_narration_detection_threshold() -> usize {
347    6
348}
349fn default_confidence_threshold() -> f64 {
350    0.8
351}
352fn default_require_tests_pass() -> bool {
353    true
354}
355
356impl Default for AutoMergePolicy {
357    fn default() -> Self {
358        Self {
359            enabled: false,
360            max_diff_lines: default_max_diff_lines(),
361            max_files_changed: default_max_files_changed(),
362            max_modules_touched: default_max_modules_touched(),
363            sensitive_paths: default_sensitive_paths(),
364            confidence_threshold: default_confidence_threshold(),
365            require_tests_pass: default_require_tests_pass(),
366        }
367    }
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
371#[serde(rename_all = "snake_case")]
372pub enum WorkflowMode {
373    #[default]
374    Legacy,
375    Hybrid,
376    WorkflowFirst,
377}
378
379#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
380#[serde(rename_all = "snake_case")]
381pub enum OrchestratorPosition {
382    #[default]
383    Left,
384    Bottom,
385}
386
387impl WorkflowMode {
388    #[cfg_attr(not(test), allow(dead_code))]
389    pub fn legacy_runtime_enabled(self) -> bool {
390        matches!(self, Self::Legacy | Self::Hybrid)
391    }
392
393    #[cfg_attr(not(test), allow(dead_code))]
394    pub fn workflow_state_primary(self) -> bool {
395        matches!(self, Self::WorkflowFirst)
396    }
397
398    pub fn enables_runtime_surface(self) -> bool {
399        matches!(self, Self::Hybrid | Self::WorkflowFirst)
400    }
401
402    pub fn as_str(self) -> &'static str {
403        match self {
404            Self::Legacy => "legacy",
405            Self::Hybrid => "hybrid",
406            Self::WorkflowFirst => "workflow_first",
407        }
408    }
409}
410
411#[derive(Debug, Clone, Deserialize)]
412pub struct BoardConfig {
413    #[serde(default = "default_rotation_threshold")]
414    pub rotation_threshold: u32,
415    #[serde(default = "default_board_auto_dispatch")]
416    pub auto_dispatch: bool,
417    #[serde(default = "default_dispatch_stabilization_delay_secs")]
418    pub dispatch_stabilization_delay_secs: u64,
419    #[serde(default = "default_dispatch_dedup_window_secs")]
420    pub dispatch_dedup_window_secs: u64,
421    #[serde(default = "default_dispatch_manual_cooldown_secs")]
422    pub dispatch_manual_cooldown_secs: u64,
423}
424
425impl Default for BoardConfig {
426    fn default() -> Self {
427        Self {
428            rotation_threshold: default_rotation_threshold(),
429            auto_dispatch: default_board_auto_dispatch(),
430            dispatch_stabilization_delay_secs: default_dispatch_stabilization_delay_secs(),
431            dispatch_dedup_window_secs: default_dispatch_dedup_window_secs(),
432            dispatch_manual_cooldown_secs: default_dispatch_manual_cooldown_secs(),
433        }
434    }
435}
436
437#[derive(Debug, Clone, Deserialize)]
438pub struct StandupConfig {
439    #[serde(default = "default_standup_interval")]
440    pub interval_secs: u64,
441    #[serde(default = "default_output_lines")]
442    pub output_lines: u32,
443}
444
445impl Default for StandupConfig {
446    fn default() -> Self {
447        Self {
448            interval_secs: default_standup_interval(),
449            output_lines: default_output_lines(),
450        }
451    }
452}
453
454#[derive(Debug, Clone, Deserialize)]
455pub struct AutomationConfig {
456    #[serde(default = "default_enabled")]
457    pub timeout_nudges: bool,
458    #[serde(default = "default_enabled")]
459    pub standups: bool,
460    #[serde(default = "default_enabled")]
461    pub failure_pattern_detection: bool,
462    #[serde(default = "default_enabled")]
463    pub triage_interventions: bool,
464    #[serde(default = "default_enabled")]
465    pub review_interventions: bool,
466    #[serde(default = "default_enabled")]
467    pub owned_task_interventions: bool,
468    #[serde(default = "default_enabled")]
469    pub manager_dispatch_interventions: bool,
470    #[serde(default = "default_enabled")]
471    pub architect_utilization_interventions: bool,
472    #[serde(default)]
473    pub replenishment_threshold: Option<usize>,
474    #[serde(default = "default_intervention_idle_grace_secs")]
475    pub intervention_idle_grace_secs: u64,
476    #[serde(default = "default_intervention_cooldown_secs")]
477    pub intervention_cooldown_secs: u64,
478    #[serde(default = "default_utilization_recovery_interval_secs")]
479    pub utilization_recovery_interval_secs: u64,
480    #[serde(default = "default_enabled")]
481    pub commit_before_reset: bool,
482}
483
484impl Default for AutomationConfig {
485    fn default() -> Self {
486        Self {
487            timeout_nudges: default_enabled(),
488            standups: default_enabled(),
489            failure_pattern_detection: default_enabled(),
490            triage_interventions: default_enabled(),
491            review_interventions: default_enabled(),
492            owned_task_interventions: default_enabled(),
493            manager_dispatch_interventions: default_enabled(),
494            architect_utilization_interventions: default_enabled(),
495            replenishment_threshold: None,
496            intervention_idle_grace_secs: default_intervention_idle_grace_secs(),
497            intervention_cooldown_secs: default_intervention_cooldown_secs(),
498            utilization_recovery_interval_secs: default_utilization_recovery_interval_secs(),
499            commit_before_reset: default_enabled(),
500        }
501    }
502}
503
504#[derive(Debug, Clone, Deserialize)]
505pub struct LayoutConfig {
506    pub zones: Vec<ZoneDef>,
507}
508
509#[derive(Debug, Clone, Deserialize)]
510pub struct ZoneDef {
511    pub name: String,
512    pub width_pct: u32,
513    #[serde(default)]
514    pub split: Option<SplitDef>,
515}
516
517#[derive(Debug, Clone, Deserialize)]
518pub struct SplitDef {
519    pub horizontal: u32,
520}
521
522#[derive(Debug, Clone, Deserialize)]
523pub struct RoleDef {
524    pub name: String,
525    pub role_type: RoleType,
526    #[serde(default)]
527    pub agent: Option<String>,
528    #[serde(default = "default_instances")]
529    pub instances: u32,
530    #[serde(default)]
531    pub prompt: Option<String>,
532    #[serde(default)]
533    pub talks_to: Vec<String>,
534    #[serde(default)]
535    pub channel: Option<String>,
536    #[serde(default)]
537    pub channel_config: Option<ChannelConfig>,
538    #[serde(default)]
539    pub nudge_interval_secs: Option<u64>,
540    #[serde(default)]
541    pub receives_standup: Option<bool>,
542    #[serde(default)]
543    pub standup_interval_secs: Option<u64>,
544    #[serde(default)]
545    #[allow(dead_code)] // Parsed for future ownership semantics but not yet enforced.
546    pub owns: Vec<String>,
547    #[serde(default)]
548    pub use_worktrees: bool,
549}
550
551#[derive(Debug, Clone, Deserialize)]
552pub struct ChannelConfig {
553    pub target: String,
554    pub provider: String,
555    /// Telegram bot token for native API (optional; falls back to provider CLI).
556    /// Can also be set via `BATTY_TELEGRAM_BOT_TOKEN` env var.
557    #[serde(default)]
558    pub bot_token: Option<String>,
559    /// Telegram user IDs allowed to send messages (access control).
560    #[serde(default)]
561    pub allowed_user_ids: Vec<i64>,
562}
563
564#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
565#[serde(rename_all = "lowercase")]
566pub enum RoleType {
567    User,
568    Architect,
569    Manager,
570    Engineer,
571}
572
573// --- Default value functions ---
574
575fn default_rotation_threshold() -> u32 {
576    20
577}
578
579fn default_workflow_mode() -> WorkflowMode {
580    WorkflowMode::Legacy
581}
582
583fn default_board_auto_dispatch() -> bool {
584    true
585}
586
587fn default_dispatch_stabilization_delay_secs() -> u64 {
588    30
589}
590
591fn default_dispatch_dedup_window_secs() -> u64 {
592    60
593}
594
595fn default_dispatch_manual_cooldown_secs() -> u64 {
596    30
597}
598
599fn default_standup_interval() -> u64 {
600    300
601}
602
603fn default_pipeline_starvation_threshold() -> Option<usize> {
604    Some(1)
605}
606
607fn default_output_lines() -> u32 {
608    30
609}
610
611fn default_instances() -> u32 {
612    1
613}
614
615fn default_escalation_threshold_secs() -> u64 {
616    3600
617}
618
619fn default_review_nudge_threshold_secs() -> u64 {
620    1800
621}
622
623fn default_review_timeout_secs() -> u64 {
624    7200
625}
626
627fn default_stall_threshold_secs() -> u64 {
628    300
629}
630
631fn default_max_stall_restarts() -> u32 {
632    2
633}
634
635fn default_health_check_interval_secs() -> u64 {
636    60
637}
638
639fn default_uncommitted_warn_threshold() -> usize {
640    200
641}
642
643fn default_enabled() -> bool {
644    true
645}
646
647fn default_orchestrator_pane() -> bool {
648    true
649}
650
651fn default_intervention_idle_grace_secs() -> u64 {
652    60
653}
654
655fn default_intervention_cooldown_secs() -> u64 {
656    120
657}
658
659fn default_utilization_recovery_interval_secs() -> u64 {
660    1200
661}
662
663fn default_event_log_max_bytes() -> u64 {
664    DEFAULT_EVENT_LOG_MAX_BYTES
665}
666
667fn default_retro_min_duration_secs() -> u64 {
668    60
669}
670
671fn default_shim_health_check_interval_secs() -> u64 {
672    60
673}
674
675fn default_shim_health_timeout_secs() -> u64 {
676    120
677}
678
679fn default_shim_shutdown_timeout_secs() -> u32 {
680    30
681}
682
683fn default_shim_working_state_timeout_secs() -> u64 {
684    600 // 10 minutes
685}
686
687fn default_pending_queue_max_age_secs() -> u64 {
688    600 // 10 minutes
689}