Skip to main content

ta_changeset/
channel_registry.rs

1// channel_registry.rs — Pluggable IO channel system (v0.7.0).
2//
3// All channels (CLI, web, Slack, Discord, email) are equal: they share the
4// same ChannelFactory trait and register in the ChannelRegistry at startup.
5// Channel routing config (`.ta/config.yaml`) determines which channel handles
6// which concern (review, notify, session, escalation).
7
8use std::collections::HashMap;
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use crate::review_channel::{ReviewChannel, ReviewChannelError};
14use crate::session_channel::{SessionChannel, SessionChannelError};
15
16/// What a channel implementation can do.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ChannelCapabilitySet {
19    /// Can this channel deliver review interactions (approve/deny)?
20    pub supports_review: bool,
21    /// Can this channel stream session events (agent output, etc.)?
22    pub supports_session: bool,
23    /// Can this channel deliver notifications?
24    pub supports_notify: bool,
25    /// Does this channel support rich media (images, code blocks, buttons)?
26    pub supports_rich_media: bool,
27    /// Does this channel support threaded conversations?
28    pub supports_threads: bool,
29}
30
31impl Default for ChannelCapabilitySet {
32    fn default() -> Self {
33        Self {
34            supports_review: true,
35            supports_session: true,
36            supports_notify: true,
37            supports_rich_media: false,
38            supports_threads: false,
39        }
40    }
41}
42
43/// Factory trait for creating channel instances.
44///
45/// Each channel plugin (terminal, Slack, Discord, email, webhook) implements
46/// this trait. The registry holds factories, and the routing config decides
47/// which factory handles which concern.
48pub trait ChannelFactory: Send + Sync {
49    /// Channel type name (e.g., "terminal", "slack", "discord", "email", "webhook").
50    fn channel_type(&self) -> &str;
51
52    /// Create a ReviewChannel for human review interactions.
53    fn build_review(
54        &self,
55        config: &serde_json::Value,
56    ) -> Result<Box<dyn ReviewChannel>, ReviewChannelError>;
57
58    /// Create a SessionChannel for agent-human streaming.
59    fn build_session(
60        &self,
61        config: &serde_json::Value,
62    ) -> Result<Box<dyn SessionChannel>, SessionChannelError>;
63
64    /// What this channel type supports.
65    fn capabilities(&self) -> ChannelCapabilitySet;
66}
67
68/// Registry of channel factories, keyed by channel type name.
69pub struct ChannelRegistry {
70    factories: HashMap<String, Box<dyn ChannelFactory>>,
71}
72
73impl ChannelRegistry {
74    /// Create a new empty registry.
75    pub fn new() -> Self {
76        Self {
77            factories: HashMap::new(),
78        }
79    }
80
81    /// Register a channel factory.
82    pub fn register(&mut self, factory: Box<dyn ChannelFactory>) {
83        let name = factory.channel_type().to_string();
84        self.factories.insert(name, factory);
85    }
86
87    /// Get a factory by channel type.
88    pub fn get(&self, channel_type: &str) -> Option<&dyn ChannelFactory> {
89        self.factories.get(channel_type).map(|f| f.as_ref())
90    }
91
92    /// List all registered channel types.
93    pub fn channel_types(&self) -> Vec<&str> {
94        self.factories.keys().map(|k| k.as_str()).collect()
95    }
96
97    /// Check if a channel type is registered.
98    pub fn has_channel(&self, channel_type: &str) -> bool {
99        self.factories.contains_key(channel_type)
100    }
101
102    /// Number of registered channel factories.
103    pub fn len(&self) -> usize {
104        self.factories.len()
105    }
106
107    /// Whether the registry is empty.
108    pub fn is_empty(&self) -> bool {
109        self.factories.is_empty()
110    }
111
112    /// Build a ReviewChannel from routing config.
113    pub fn build_review_from_config(
114        &self,
115        route: &ChannelRouteConfig,
116    ) -> Result<Box<dyn ReviewChannel>, ReviewChannelError> {
117        let factory = self.get(&route.channel_type).ok_or_else(|| {
118            ReviewChannelError::Other(format!(
119                "unknown channel type: '{}'. Registered: {:?}",
120                route.channel_type,
121                self.channel_types()
122            ))
123        })?;
124        factory.build_review(&route.config)
125    }
126
127    /// Build a ReviewChannel from a ReviewRouteConfig (single or multi).
128    ///
129    /// If the config specifies multiple channels, returns a `MultiReviewChannel`
130    /// wrapping all of them. If single, returns the channel directly.
131    pub fn build_review_from_route(
132        &self,
133        route: &ReviewRouteConfig,
134        strategy: &crate::multi_channel::MultiChannelStrategy,
135    ) -> Result<Box<dyn ReviewChannel>, ReviewChannelError> {
136        let configs = route.configs();
137        if configs.len() == 1 {
138            return self.build_review_from_config(configs[0]);
139        }
140        let mut channels: Vec<Box<dyn ReviewChannel>> = Vec::with_capacity(configs.len());
141        for config in configs {
142            channels.push(self.build_review_from_config(config)?);
143        }
144        Ok(Box::new(crate::multi_channel::MultiReviewChannel::new(
145            channels,
146            strategy.clone(),
147        )))
148    }
149
150    /// Build a SessionChannel from routing config.
151    pub fn build_session_from_config(
152        &self,
153        route: &ChannelRouteConfig,
154    ) -> Result<Box<dyn SessionChannel>, SessionChannelError> {
155        let factory = self.get(&route.channel_type).ok_or_else(|| {
156            SessionChannelError::Other(format!(
157                "unknown channel type: '{}'. Registered: {:?}",
158                route.channel_type,
159                self.channel_types()
160            ))
161        })?;
162        factory.build_session(&route.config)
163    }
164}
165
166impl Default for ChannelRegistry {
167    fn default() -> Self {
168        Self::new()
169    }
170}
171
172/// A single channel routing entry.
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct ChannelRouteConfig {
175    /// Channel type (must match a registered ChannelFactory).
176    #[serde(rename = "type")]
177    pub channel_type: String,
178    /// Channel-specific config (Slack channel, email address, etc.).
179    #[serde(flatten)]
180    pub config: serde_json::Value,
181}
182
183impl Default for ChannelRouteConfig {
184    fn default() -> Self {
185        Self {
186            channel_type: "terminal".to_string(),
187            config: serde_json::Value::Object(serde_json::Map::new()),
188        }
189    }
190}
191
192/// Notification routing entry (supports multiple targets).
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct NotifyRouteConfig {
195    /// Channel type.
196    #[serde(rename = "type")]
197    pub channel_type: String,
198    /// Minimum notification level to deliver: "debug", "info", "warning", "error".
199    #[serde(default = "default_notify_level")]
200    pub level: String,
201    /// Channel-specific config.
202    #[serde(flatten)]
203    pub config: serde_json::Value,
204}
205
206fn default_notify_level() -> String {
207    "info".to_string()
208}
209
210/// One-or-many channel route config (v0.10.0 multi-channel routing).
211///
212/// Accepts either a single channel object or an array of channels:
213/// ```yaml
214/// review: { type: terminal }          # single
215/// review:                              # multiple
216///   - { type: terminal }
217///   - { type: webhook, endpoint: /tmp/review }
218/// ```
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(untagged)]
221pub enum ReviewRouteConfig {
222    /// A single channel (backward-compatible default).
223    Single(ChannelRouteConfig),
224    /// Multiple channels — dispatched via MultiReviewChannel.
225    Multiple(Vec<ChannelRouteConfig>),
226}
227
228impl Default for ReviewRouteConfig {
229    fn default() -> Self {
230        Self::Single(ChannelRouteConfig::default())
231    }
232}
233
234impl ReviewRouteConfig {
235    /// Return the list of channel configs (always at least one).
236    pub fn configs(&self) -> Vec<&ChannelRouteConfig> {
237        match self {
238            Self::Single(c) => vec![c],
239            Self::Multiple(cs) => cs.iter().collect(),
240        }
241    }
242
243    /// True if this specifies more than one channel.
244    pub fn is_multi(&self) -> bool {
245        matches!(self, Self::Multiple(cs) if cs.len() > 1)
246    }
247}
248
249/// One-or-many escalation route config (v0.10.0).
250#[derive(Debug, Clone, Serialize, Deserialize)]
251#[serde(untagged)]
252pub enum EscalationRouteConfig {
253    /// A single escalation channel.
254    Single(ChannelRouteConfig),
255    /// Multiple escalation channels.
256    Multiple(Vec<ChannelRouteConfig>),
257}
258
259impl EscalationRouteConfig {
260    /// Return the list of channel configs.
261    pub fn configs(&self) -> Vec<&ChannelRouteConfig> {
262        match self {
263            Self::Single(c) => vec![c],
264            Self::Multiple(cs) => cs.iter().collect(),
265        }
266    }
267}
268
269/// Top-level channel routing configuration.
270///
271/// Loaded from `.ta/config.yaml`:
272/// ```yaml
273/// channels:
274///   review: { type: terminal }                  # single channel
275///   review:                                      # multi-channel (v0.10.0)
276///     - { type: terminal }
277///     - { type: webhook, endpoint: /tmp/review }
278///   notify:
279///     - { type: terminal }
280///     - { type: slack, channel: "#reviews", level: warning }
281///   session: { type: terminal }
282///   escalation: { type: email, to: "mgr@co.com" }
283///   strategy: first_response                     # or "quorum"
284/// ```
285#[derive(Debug, Clone, Default, Serialize, Deserialize)]
286pub struct ChannelRoutingConfig {
287    /// Channel(s) for review interactions (draft approve/deny).
288    /// Supports a single channel or array of channels (v0.10.0).
289    #[serde(default)]
290    pub review: ReviewRouteConfig,
291    /// Channels for notifications (can be multiple).
292    #[serde(default)]
293    pub notify: Vec<NotifyRouteConfig>,
294    /// Channel for interactive sessions.
295    #[serde(default)]
296    pub session: ChannelRouteConfig,
297    /// Channel(s) for escalation (high-priority or supervisor review).
298    /// Supports a single channel or array of channels (v0.10.0).
299    #[serde(default)]
300    pub escalation: Option<EscalationRouteConfig>,
301    /// Default agent to assign when requests come in through a channel.
302    #[serde(default)]
303    pub default_agent: Option<String>,
304    /// Default workflow to use for channel-initiated goals.
305    #[serde(default)]
306    pub default_workflow: Option<String>,
307    /// Multi-channel dispatch strategy (v0.10.0): "first_response" (default) or "quorum".
308    #[serde(default)]
309    pub strategy: Option<String>,
310}
311
312// ChannelRoutingConfig derives Default since all fields have Default implementations.
313
314/// Wrapper for `.ta/config.yaml` — the channels section lives here.
315#[derive(Debug, Clone, Default, Serialize, Deserialize)]
316pub struct TaConfig {
317    /// Channel routing configuration.
318    #[serde(default)]
319    pub channels: ChannelRoutingConfig,
320}
321
322/// Load `.ta/config.yaml` from project root.
323pub fn load_config(project_root: &Path) -> TaConfig {
324    let config_path = project_root.join(".ta").join("config.yaml");
325    if !config_path.exists() {
326        return TaConfig::default();
327    }
328    match std::fs::read_to_string(&config_path) {
329        Ok(content) => serde_yaml::from_str(&content).unwrap_or_default(),
330        Err(_) => TaConfig::default(),
331    }
332}
333
334/// Built-in terminal channel factory.
335///
336/// Always available — provides CLI-based review and session channels.
337pub struct TerminalChannelFactory;
338
339impl ChannelFactory for TerminalChannelFactory {
340    fn channel_type(&self) -> &str {
341        "terminal"
342    }
343
344    fn build_review(
345        &self,
346        _config: &serde_json::Value,
347    ) -> Result<Box<dyn ReviewChannel>, ReviewChannelError> {
348        Ok(Box::new(crate::terminal_channel::TerminalChannel::stdio()))
349    }
350
351    fn build_session(
352        &self,
353        _config: &serde_json::Value,
354    ) -> Result<Box<dyn SessionChannel>, SessionChannelError> {
355        Ok(Box::new(
356            crate::terminal_channel::TerminalSessionChannel::new(),
357        ))
358    }
359
360    fn capabilities(&self) -> ChannelCapabilitySet {
361        ChannelCapabilitySet {
362            supports_review: true,
363            supports_session: true,
364            supports_notify: true,
365            supports_rich_media: false,
366            supports_threads: false,
367        }
368    }
369}
370
371/// Built-in auto-approve channel factory (for testing/CI).
372pub struct AutoApproveChannelFactory;
373
374impl ChannelFactory for AutoApproveChannelFactory {
375    fn channel_type(&self) -> &str {
376        "auto-approve"
377    }
378
379    fn build_review(
380        &self,
381        _config: &serde_json::Value,
382    ) -> Result<Box<dyn ReviewChannel>, ReviewChannelError> {
383        Ok(Box::new(crate::terminal_channel::AutoApproveChannel::new()))
384    }
385
386    fn build_session(
387        &self,
388        _config: &serde_json::Value,
389    ) -> Result<Box<dyn SessionChannel>, SessionChannelError> {
390        // Auto-approve doesn't have meaningful session interaction.
391        Ok(Box::new(
392            crate::terminal_channel::TerminalSessionChannel::new(),
393        ))
394    }
395
396    fn capabilities(&self) -> ChannelCapabilitySet {
397        ChannelCapabilitySet {
398            supports_review: true,
399            supports_session: false,
400            supports_notify: false,
401            supports_rich_media: false,
402            supports_threads: false,
403        }
404    }
405}
406
407/// Built-in webhook channel factory.
408pub struct WebhookChannelFactory;
409
410impl ChannelFactory for WebhookChannelFactory {
411    fn channel_type(&self) -> &str {
412        "webhook"
413    }
414
415    fn build_review(
416        &self,
417        config: &serde_json::Value,
418    ) -> Result<Box<dyn ReviewChannel>, ReviewChannelError> {
419        let endpoint = config
420            .get("endpoint")
421            .and_then(|v| v.as_str())
422            .ok_or_else(|| {
423                ReviewChannelError::Other("webhook requires 'endpoint' in config".into())
424            })?;
425        Ok(Box::new(crate::webhook_channel::WebhookChannel::new(
426            endpoint,
427        )))
428    }
429
430    fn build_session(
431        &self,
432        _config: &serde_json::Value,
433    ) -> Result<Box<dyn SessionChannel>, SessionChannelError> {
434        // Webhook doesn't support bidirectional sessions.
435        Err(SessionChannelError::Other(
436            "webhook does not support interactive sessions".into(),
437        ))
438    }
439
440    fn capabilities(&self) -> ChannelCapabilitySet {
441        ChannelCapabilitySet {
442            supports_review: true,
443            supports_session: false,
444            supports_notify: true,
445            supports_rich_media: false,
446            supports_threads: false,
447        }
448    }
449}
450
451/// Create a ChannelRegistry pre-loaded with all built-in channel factories.
452pub fn default_registry() -> ChannelRegistry {
453    let mut registry = ChannelRegistry::new();
454    registry.register(Box::new(TerminalChannelFactory));
455    registry.register(Box::new(AutoApproveChannelFactory));
456    registry.register(Box::new(WebhookChannelFactory));
457    registry
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn default_registry_has_builtins() {
466        let registry = default_registry();
467        assert!(registry.has_channel("terminal"));
468        assert!(registry.has_channel("auto-approve"));
469        assert!(registry.has_channel("webhook"));
470        assert!(!registry.has_channel("slack"));
471        assert_eq!(registry.len(), 3);
472    }
473
474    #[test]
475    fn build_review_from_config() {
476        let registry = default_registry();
477        let route = ChannelRouteConfig {
478            channel_type: "terminal".into(),
479            config: serde_json::json!({}),
480        };
481        let channel = registry.build_review_from_config(&route);
482        assert!(channel.is_ok());
483    }
484
485    #[test]
486    fn build_review_unknown_type_errors() {
487        let registry = default_registry();
488        let route = ChannelRouteConfig {
489            channel_type: "slack".into(),
490            config: serde_json::json!({}),
491        };
492        let result = registry.build_review_from_config(&route);
493        assert!(result.is_err());
494    }
495
496    #[test]
497    fn channel_routing_config_single_review() {
498        let yaml = r#"
499review:
500  type: terminal
501notify:
502  - type: terminal
503  - type: webhook
504    endpoint: "/tmp/notify"
505    level: warning
506session:
507  type: terminal
508escalation:
509  type: webhook
510  endpoint: "/tmp/escalate"
511default_agent: claude-code
512"#;
513        let config: ChannelRoutingConfig = serde_yaml::from_str(yaml).unwrap();
514        let review_configs = config.review.configs();
515        assert_eq!(review_configs.len(), 1);
516        assert_eq!(review_configs[0].channel_type, "terminal");
517        assert!(!config.review.is_multi());
518        assert_eq!(config.notify.len(), 2);
519        assert_eq!(config.notify[1].channel_type, "webhook");
520        assert_eq!(config.notify[1].level, "warning");
521        assert!(config.escalation.is_some());
522        assert_eq!(config.default_agent.as_deref(), Some("claude-code"));
523    }
524
525    #[test]
526    fn channel_routing_config_multi_review() {
527        let yaml = r#"
528review:
529  - type: terminal
530  - type: webhook
531    endpoint: "/tmp/review"
532session:
533  type: terminal
534strategy: first_response
535"#;
536        let config: ChannelRoutingConfig = serde_yaml::from_str(yaml).unwrap();
537        let review_configs = config.review.configs();
538        assert_eq!(review_configs.len(), 2);
539        assert_eq!(review_configs[0].channel_type, "terminal");
540        assert_eq!(review_configs[1].channel_type, "webhook");
541        assert!(config.review.is_multi());
542        assert_eq!(config.strategy.as_deref(), Some("first_response"));
543    }
544
545    #[test]
546    fn channel_routing_config_multi_escalation() {
547        let yaml = r#"
548review:
549  type: terminal
550session:
551  type: terminal
552escalation:
553  - type: webhook
554    endpoint: "/tmp/esc1"
555  - type: webhook
556    endpoint: "/tmp/esc2"
557"#;
558        let config: ChannelRoutingConfig = serde_yaml::from_str(yaml).unwrap();
559        let esc = config.escalation.unwrap();
560        assert_eq!(esc.configs().len(), 2);
561    }
562
563    #[test]
564    fn ta_config_deserialization() {
565        let yaml = r#"
566channels:
567  review:
568    type: terminal
569  session:
570    type: terminal
571"#;
572        let config: TaConfig = serde_yaml::from_str(yaml).unwrap();
573        let review_configs = config.channels.review.configs();
574        assert_eq!(review_configs[0].channel_type, "terminal");
575    }
576
577    #[test]
578    fn default_ta_config() {
579        let config = TaConfig::default();
580        let review_configs = config.channels.review.configs();
581        assert_eq!(review_configs[0].channel_type, "terminal");
582        assert!(config.channels.notify.is_empty());
583    }
584
585    #[test]
586    fn build_multi_review_from_route_single() {
587        let registry = default_registry();
588        let route = ReviewRouteConfig::Single(ChannelRouteConfig {
589            channel_type: "terminal".into(),
590            config: serde_json::json!({}),
591        });
592        let strategy = crate::multi_channel::MultiChannelStrategy::FirstResponse;
593        let channel = registry.build_review_from_route(&route, &strategy);
594        assert!(channel.is_ok());
595    }
596
597    #[test]
598    fn build_multi_review_from_route_multiple() {
599        let registry = default_registry();
600        let route = ReviewRouteConfig::Multiple(vec![
601            ChannelRouteConfig {
602                channel_type: "auto-approve".into(),
603                config: serde_json::json!({}),
604            },
605            ChannelRouteConfig {
606                channel_type: "auto-approve".into(),
607                config: serde_json::json!({}),
608            },
609        ]);
610        let strategy = crate::multi_channel::MultiChannelStrategy::FirstResponse;
611        let channel = registry.build_review_from_route(&route, &strategy);
612        assert!(channel.is_ok());
613    }
614
615    #[test]
616    fn channel_capability_set_defaults() {
617        let caps = ChannelCapabilitySet::default();
618        assert!(caps.supports_review);
619        assert!(caps.supports_session);
620        assert!(caps.supports_notify);
621        assert!(!caps.supports_rich_media);
622        assert!(!caps.supports_threads);
623    }
624
625    #[test]
626    fn register_custom_factory() {
627        struct MockFactory;
628        impl ChannelFactory for MockFactory {
629            fn channel_type(&self) -> &str {
630                "mock"
631            }
632            fn build_review(
633                &self,
634                _config: &serde_json::Value,
635            ) -> Result<Box<dyn ReviewChannel>, ReviewChannelError> {
636                Ok(Box::new(crate::terminal_channel::AutoApproveChannel::new()))
637            }
638            fn build_session(
639                &self,
640                _config: &serde_json::Value,
641            ) -> Result<Box<dyn SessionChannel>, SessionChannelError> {
642                Err(SessionChannelError::Other("mock".into()))
643            }
644            fn capabilities(&self) -> ChannelCapabilitySet {
645                ChannelCapabilitySet::default()
646            }
647        }
648
649        let mut registry = default_registry();
650        registry.register(Box::new(MockFactory));
651        assert!(registry.has_channel("mock"));
652        assert_eq!(registry.len(), 4);
653    }
654
655    #[test]
656    fn load_config_missing_file() {
657        let dir = tempfile::TempDir::new().unwrap();
658        let config = load_config(dir.path());
659        assert_eq!(config.channels.review.configs()[0].channel_type, "terminal");
660    }
661
662    #[test]
663    fn webhook_factory_requires_endpoint() {
664        let registry = default_registry();
665        let route = ChannelRouteConfig {
666            channel_type: "webhook".into(),
667            config: serde_json::json!({}),
668        };
669        let result = registry.build_review_from_config(&route);
670        assert!(result.is_err());
671    }
672}