Skip to main content

tmai_core/api/
core.rs

1//! TmaiCore — the Facade entry-point for all consumers (TUI, Web, MCP, etc.)
2//!
3//! This struct owns every shared service and exposes high-level methods.
4//! Consumers never need to acquire locks or wire services themselves.
5
6use std::sync::Arc;
7
8use parking_lot::RwLock;
9use tokio::sync::broadcast;
10
11use crate::audit::helper::AuditHelper;
12use crate::audit::AuditEventSender;
13use crate::auto_approve::defer::DeferRegistry;
14use crate::command_sender::CommandSender;
15use crate::config::Settings;
16use crate::hooks::registry::{HookRegistry, SessionPaneMap};
17use crate::ipc::server::IpcServer;
18use crate::pty::PtyRegistry;
19use crate::runtime::RuntimeAdapter;
20use crate::state::SharedState;
21use crate::transcript::TranscriptRegistry;
22
23use super::events::CoreEvent;
24
25/// Default broadcast channel capacity
26const EVENT_CHANNEL_CAPACITY: usize = 256;
27
28/// The Facade that wraps all tmai-core services.
29///
30/// Constructed via [`TmaiCoreBuilder`](super::builder::TmaiCoreBuilder).
31pub struct TmaiCore {
32    /// Shared application state (agents, teams, UI state)
33    state: SharedState,
34    /// Unified command sender (IPC + tmux fallback)
35    command_sender: Option<Arc<CommandSender>>,
36    /// Application settings (hot-reloadable via `reload_settings()`)
37    settings: RwLock<Arc<Settings>>,
38    /// IPC server for PTY wrapper communication
39    ipc_server: Option<Arc<IpcServer>>,
40    /// Broadcast sender for core events
41    event_tx: broadcast::Sender<CoreEvent>,
42    /// Audit helper for emitting user-input-during-processing events
43    audit_helper: AuditHelper,
44    /// Hook registry for HTTP hook-based agent state
45    hook_registry: HookRegistry,
46    /// Session ID → pane ID mapping for hook event routing
47    session_pane_map: SessionPaneMap,
48    /// Authentication token for hook endpoints
49    hook_token: Option<String>,
50    /// PTY session registry for spawned agents
51    pty_registry: Arc<PtyRegistry>,
52    /// Runtime adapter (tmux, standalone, etc.)
53    runtime: Option<Arc<dyn RuntimeAdapter>>,
54    /// Transcript registry for JSONL conversation log monitoring
55    transcript_registry: Option<TranscriptRegistry>,
56    /// Registry for deferred tool calls pending resolution
57    defer_registry: Arc<DeferRegistry>,
58}
59
60impl TmaiCore {
61    /// Create a new TmaiCore instance (prefer `TmaiCoreBuilder`)
62    #[allow(clippy::too_many_arguments)]
63    pub(crate) fn new(
64        state: SharedState,
65        command_sender: Option<Arc<CommandSender>>,
66        settings: Arc<Settings>,
67        ipc_server: Option<Arc<IpcServer>>,
68        audit_tx: Option<AuditEventSender>,
69        hook_registry: HookRegistry,
70        session_pane_map: SessionPaneMap,
71        hook_token: Option<String>,
72        pty_registry: Arc<PtyRegistry>,
73        runtime: Option<Arc<dyn RuntimeAdapter>>,
74        transcript_registry: Option<TranscriptRegistry>,
75        defer_registry: Arc<DeferRegistry>,
76    ) -> Self {
77        let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
78        let audit_helper = AuditHelper::new(audit_tx, state.clone());
79        Self {
80            state,
81            command_sender,
82            settings: RwLock::new(settings),
83            ipc_server,
84            event_tx,
85            audit_helper,
86            hook_registry,
87            session_pane_map,
88            hook_token,
89            pty_registry,
90            runtime,
91            transcript_registry,
92            defer_registry,
93        }
94    }
95
96    // =========================================================
97    // Escape hatches — for gradual migration from raw state access
98    // =========================================================
99
100    /// Access the raw shared state.
101    ///
102    /// **Deprecated**: prefer using typed query/action methods on `TmaiCore`.
103    /// This escape hatch exists for incremental migration only.
104    #[deprecated(note = "Use TmaiCore query/action methods instead of direct state access")]
105    pub fn raw_state(&self) -> &SharedState {
106        &self.state
107    }
108
109    /// Access the raw command sender.
110    ///
111    /// **Deprecated**: prefer using action methods on `TmaiCore`.
112    /// This escape hatch exists for incremental migration only.
113    #[deprecated(note = "Use TmaiCore action methods instead of direct CommandSender access")]
114    pub fn raw_command_sender(&self) -> Option<&Arc<CommandSender>> {
115        self.command_sender.as_ref()
116    }
117
118    /// Access application settings (read-only snapshot)
119    ///
120    /// Returns a cheap `Arc` clone. The underlying settings can be
121    /// hot-reloaded via [`reload_settings()`](Self::reload_settings).
122    pub fn settings(&self) -> Arc<Settings> {
123        self.settings.read().clone()
124    }
125
126    /// Re-read `config.toml` and replace the live settings.
127    ///
128    /// Called after PUT `/api/settings/*` handlers persist changes to disk.
129    /// Returns `true` if the reload succeeded.
130    pub fn reload_settings(&self) -> bool {
131        match Settings::load(None) {
132            Ok(new_settings) => {
133                *self.settings.write() = Arc::new(new_settings);
134                tracing::debug!("Settings reloaded from config.toml");
135                true
136            }
137            Err(e) => {
138                tracing::warn!(%e, "Failed to reload settings from config.toml");
139                false
140            }
141        }
142    }
143
144    /// Access the IPC server (if configured)
145    pub fn ipc_server(&self) -> Option<&Arc<IpcServer>> {
146        self.ipc_server.as_ref()
147    }
148
149    /// Get a clone of the broadcast event sender.
150    ///
151    /// Used by the Poller to emit TeammateIdle/TaskCompleted events,
152    /// and by the SSE handler to subscribe to events.
153    pub fn event_sender(&self) -> broadcast::Sender<CoreEvent> {
154        self.event_tx.clone()
155    }
156
157    // =========================================================
158    // Internal accessors for query/action impls
159    // =========================================================
160
161    /// Borrow the shared state (for query/action modules)
162    pub(crate) fn state(&self) -> &SharedState {
163        &self.state
164    }
165
166    /// Borrow the command sender (for action modules)
167    pub(crate) fn command_sender_ref(&self) -> Option<&Arc<CommandSender>> {
168        self.command_sender.as_ref()
169    }
170
171    /// Borrow the audit helper (for action modules)
172    pub(crate) fn audit_helper(&self) -> &AuditHelper {
173        &self.audit_helper
174    }
175
176    // =========================================================
177    // Hook accessors
178    // =========================================================
179
180    /// Access the hook registry for HTTP hook-based agent state
181    pub fn hook_registry(&self) -> &HookRegistry {
182        &self.hook_registry
183    }
184
185    /// Access the session → pane ID mapping
186    pub fn session_pane_map(&self) -> &SessionPaneMap {
187        &self.session_pane_map
188    }
189
190    /// Get the hook token (if configured)
191    pub fn hook_token(&self) -> Option<&str> {
192        self.hook_token.as_deref()
193    }
194
195    /// Access the PTY session registry
196    pub fn pty_registry(&self) -> &Arc<PtyRegistry> {
197        &self.pty_registry
198    }
199
200    /// Access the runtime adapter (if set)
201    pub fn runtime(&self) -> Option<&Arc<dyn RuntimeAdapter>> {
202        self.runtime.as_ref()
203    }
204
205    /// Access the transcript registry (if configured)
206    pub fn transcript_registry(&self) -> Option<&TranscriptRegistry> {
207        self.transcript_registry.as_ref()
208    }
209
210    /// Access the deferred tool call registry
211    pub fn defer_registry(&self) -> &Arc<DeferRegistry> {
212        &self.defer_registry
213    }
214
215    /// Direct write access to settings (for testing)
216    #[cfg(test)]
217    pub(crate) fn settings_mut(&self) -> parking_lot::RwLockWriteGuard<'_, Arc<Settings>> {
218        self.settings.write()
219    }
220
221    /// Validate a hook authentication token (constant-time comparison)
222    pub fn validate_hook_token(&self, token: &str) -> bool {
223        match &self.hook_token {
224            Some(expected) => {
225                // Constant-time comparison to prevent timing side-channel attacks.
226                // We always iterate over the expected token length to avoid
227                // leaking length information via timing.
228                let expected_bytes = expected.as_bytes();
229                let token_bytes = token.as_bytes();
230                let mut result: usize = expected_bytes.len() ^ token_bytes.len();
231                for i in 0..expected_bytes.len() {
232                    let token_byte = if i < token_bytes.len() {
233                        token_bytes[i]
234                    } else {
235                        // Use a value that will never match to avoid short-circuit
236                        0xFF
237                    };
238                    result |= (expected_bytes[i] ^ token_byte) as usize;
239                }
240                result == 0
241            }
242            None => false,
243        }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::state::AppState;
251
252    #[test]
253    fn test_tmai_core_creation() {
254        let state = AppState::shared();
255        let settings = Arc::new(Settings::default());
256        let hook_registry = crate::hooks::new_hook_registry();
257        let session_pane_map = crate::hooks::new_session_pane_map();
258        let core = TmaiCore::new(
259            state,
260            None,
261            settings.clone(),
262            None,
263            None,
264            hook_registry,
265            session_pane_map,
266            None,
267            crate::pty::PtyRegistry::new(),
268            None,
269            None,
270            DeferRegistry::new(),
271        );
272
273        assert_eq!(core.settings().poll_interval_ms, 500);
274        assert!(core.ipc_server().is_none());
275        assert!(core.command_sender_ref().is_none());
276    }
277
278    #[test]
279    #[allow(deprecated)]
280    fn test_escape_hatches() {
281        let state = AppState::shared();
282        let settings = Arc::new(Settings::default());
283        let hook_registry = crate::hooks::new_hook_registry();
284        let session_pane_map = crate::hooks::new_session_pane_map();
285        let core = TmaiCore::new(
286            state.clone(),
287            None,
288            settings,
289            None,
290            None,
291            hook_registry,
292            session_pane_map,
293            None,
294            crate::pty::PtyRegistry::new(),
295            None,
296            None,
297            DeferRegistry::new(),
298        );
299
300        // raw_state should return the same Arc
301        let raw = core.raw_state();
302        assert!(Arc::ptr_eq(raw, &state));
303
304        // raw_command_sender should be None
305        assert!(core.raw_command_sender().is_none());
306    }
307
308    #[test]
309    fn test_hook_token_validation() {
310        let state = AppState::shared();
311        let settings = Arc::new(Settings::default());
312        let hook_registry = crate::hooks::new_hook_registry();
313        let session_pane_map = crate::hooks::new_session_pane_map();
314        let core = TmaiCore::new(
315            state,
316            None,
317            settings,
318            None,
319            None,
320            hook_registry,
321            session_pane_map,
322            Some("test-token-123".to_string()),
323            crate::pty::PtyRegistry::new(),
324            None,
325            None,
326            DeferRegistry::new(),
327        );
328
329        assert!(core.validate_hook_token("test-token-123"));
330        assert!(!core.validate_hook_token("wrong-token"));
331    }
332
333    #[test]
334    fn test_settings_returns_arc_clone() {
335        let mut custom = Settings::default();
336        custom.poll_interval_ms = 1234;
337        let core = crate::api::TmaiCoreBuilder::new(custom).build();
338
339        let s1 = core.settings();
340        let s2 = core.settings();
341        assert_eq!(s1.poll_interval_ms, 1234);
342        assert_eq!(s2.poll_interval_ms, 1234);
343        // Both should point to the same underlying allocation
344        assert!(Arc::ptr_eq(&s1, &s2));
345    }
346
347    #[test]
348    fn test_reload_settings_with_tempdir() {
349        // Create a temp config file with a custom poll_interval_ms
350        let dir = tempfile::tempdir().unwrap();
351        let config_path = dir.path().join("config.toml");
352        std::fs::write(&config_path, "poll_interval_ms = 999\n").unwrap();
353
354        let initial = Settings::load(Some(&config_path)).unwrap();
355        assert_eq!(initial.poll_interval_ms, 999);
356
357        let core = crate::api::TmaiCoreBuilder::new(initial).build();
358        assert_eq!(core.settings().poll_interval_ms, 999);
359
360        // Modify config on disk
361        std::fs::write(&config_path, "poll_interval_ms = 2000\n").unwrap();
362
363        // reload_settings() reads from the default config path, not our temp file,
364        // so we test the mechanism indirectly: verify settings() returns an Arc
365        // and that the RwLock swap works by calling the internal write path.
366        {
367            let new_settings = Settings::load(Some(&config_path)).unwrap();
368            assert_eq!(new_settings.poll_interval_ms, 2000);
369            *core.settings_mut() = Arc::new(new_settings);
370        }
371        assert_eq!(core.settings().poll_interval_ms, 2000);
372    }
373}