Skip to main content

tmai_core/api/
queries.rs

1//! Read-only query methods on [`TmaiCore`].
2//!
3//! Every method acquires a read lock internally, converts to owned snapshots,
4//! and releases the lock before returning. Callers never hold a lock.
5
6use super::core::TmaiCore;
7use super::types::{AgentDefinitionInfo, AgentSnapshot, ApiError, TeamSummary, TeamTaskInfo};
8
9impl TmaiCore {
10    // =========================================================
11    // Agent queries
12    // =========================================================
13
14    /// List all monitored agents as owned snapshots, in current display order.
15    pub fn list_agents(&self) -> Vec<AgentSnapshot> {
16        let state = self.state().read();
17        let defs = &state.agent_definitions;
18        state
19            .agent_order
20            .iter()
21            .filter_map(|id| state.agents.get(id))
22            .map(|a| {
23                let mut snap = AgentSnapshot::from_agent(a);
24                snap.agent_definition = Self::match_agent_definition(a, defs);
25                snap
26            })
27            .collect()
28    }
29
30    /// Get a single agent snapshot by target ID.
31    pub fn get_agent(&self, target: &str) -> Result<AgentSnapshot, ApiError> {
32        let state = self.state().read();
33        let defs = &state.agent_definitions;
34        state
35            .agents
36            .get(target)
37            .map(|a| {
38                let mut snap = AgentSnapshot::from_agent(a);
39                snap.agent_definition = Self::match_agent_definition(a, defs);
40                snap
41            })
42            .ok_or_else(|| ApiError::AgentNotFound {
43                target: target.to_string(),
44            })
45    }
46
47    /// Get the currently selected agent snapshot.
48    pub fn selected_agent(&self) -> Result<AgentSnapshot, ApiError> {
49        let state = self.state().read();
50        let defs = &state.agent_definitions;
51        state
52            .selected_agent()
53            .map(|agent| {
54                let mut snapshot = AgentSnapshot::from_agent(agent);
55                snapshot.agent_definition = Self::match_agent_definition(agent, defs);
56                snapshot
57            })
58            .ok_or(ApiError::NoSelection)
59    }
60
61    /// Get the number of agents that need user attention.
62    pub fn attention_count(&self) -> usize {
63        let state = self.state().read();
64        state.attention_count()
65    }
66
67    /// Get the total number of monitored agents.
68    pub fn agent_count(&self) -> usize {
69        let state = self.state().read();
70        state.agents.len()
71    }
72
73    /// List agents that need attention (awaiting approval or error).
74    pub fn agents_needing_attention(&self) -> Vec<AgentSnapshot> {
75        let state = self.state().read();
76        state
77            .agent_order
78            .iter()
79            .filter_map(|id| state.agents.get(id))
80            .filter(|a| a.status.needs_attention())
81            .map(AgentSnapshot::from_agent)
82            .collect()
83    }
84
85    // =========================================================
86    // Preview
87    // =========================================================
88
89    /// Get the ANSI preview content for an agent.
90    pub fn get_preview(&self, target: &str) -> Result<String, ApiError> {
91        let state = self.state().read();
92        state
93            .agents
94            .get(target)
95            .map(|a| a.last_content_ansi.clone())
96            .ok_or_else(|| ApiError::AgentNotFound {
97                target: target.to_string(),
98            })
99    }
100
101    /// Get the plain-text content for an agent.
102    pub fn get_content(&self, target: &str) -> Result<String, ApiError> {
103        let state = self.state().read();
104        state
105            .agents
106            .get(target)
107            .map(|a| a.last_content.clone())
108            .ok_or_else(|| ApiError::AgentNotFound {
109                target: target.to_string(),
110            })
111    }
112
113    // =========================================================
114    // Transcript queries
115    // =========================================================
116
117    /// Get transcript records for an agent (used for hybrid scrollback preview).
118    ///
119    /// Returns parsed JSONL records from the agent's Claude Code conversation log.
120    /// The records are looked up by pane_id from the transcript registry.
121    pub fn get_transcript(
122        &self,
123        target: &str,
124    ) -> Result<Vec<crate::transcript::TranscriptRecord>, ApiError> {
125        // Verify agent exists and get pane_id
126        let pane_id = {
127            let state = self.state().read();
128            let agent = state
129                .agents
130                .get(target)
131                .ok_or_else(|| ApiError::AgentNotFound {
132                    target: target.to_string(),
133                })?;
134            // Use target_to_pane_id mapping, or fall back to using the target itself
135            state
136                .target_to_pane_id
137                .get(&agent.id)
138                .cloned()
139                .unwrap_or_else(|| agent.id.clone())
140        };
141
142        // Look up transcript records from the registry
143        let registry = match self.transcript_registry() {
144            Some(reg) => reg,
145            None => return Ok(Vec::new()),
146        };
147
148        let reg = registry.read();
149        Ok(reg
150            .get(&pane_id)
151            .map(|state| state.recent_records.clone())
152            .unwrap_or_default())
153    }
154
155    // =========================================================
156    // Team queries
157    // =========================================================
158
159    /// List all known teams as owned summaries.
160    pub fn list_teams(&self) -> Vec<TeamSummary> {
161        let state = self.state().read();
162        let mut teams: Vec<TeamSummary> = state
163            .teams
164            .values()
165            .map(TeamSummary::from_snapshot)
166            .collect();
167        teams.sort_by(|a, b| a.name.cmp(&b.name));
168        teams
169    }
170
171    /// Get a single team summary by name.
172    pub fn get_team(&self, name: &str) -> Result<TeamSummary, ApiError> {
173        let state = self.state().read();
174        state
175            .teams
176            .get(name)
177            .map(TeamSummary::from_snapshot)
178            .ok_or_else(|| ApiError::TeamNotFound {
179                name: name.to_string(),
180            })
181    }
182
183    /// Get tasks for a team.
184    pub fn get_team_tasks(&self, name: &str) -> Result<Vec<TeamTaskInfo>, ApiError> {
185        let state = self.state().read();
186        state
187            .teams
188            .get(name)
189            .map(|ts| ts.tasks.iter().map(TeamTaskInfo::from_task).collect())
190            .ok_or_else(|| ApiError::TeamNotFound {
191                name: name.to_string(),
192            })
193    }
194
195    // =========================================================
196    // Security queries
197    // =========================================================
198
199    /// Run a config audit and cache the result in state.
200    ///
201    /// Acquires a read lock to gather project directories, releases it,
202    /// runs the audit (no lock held), then acquires a write lock to store the result.
203    pub fn config_audit(&self) -> crate::security::ScanResult {
204        // Gather project directories from agent working_dir fields
205        let dirs: Vec<std::path::PathBuf> = {
206            let state = self.state().read();
207            state
208                .agents
209                .values()
210                .map(|a| std::path::PathBuf::from(&a.cwd))
211                .collect()
212        };
213
214        // Run audit without holding any lock
215        let result = crate::security::ConfigAuditScanner::scan(&dirs);
216
217        // Store result
218        {
219            let mut state = self.state().write();
220            state.config_audit = Some(result.clone());
221        }
222
223        result
224    }
225
226    /// Get the last cached config audit result (no new audit).
227    pub fn last_config_audit(&self) -> Option<crate::security::ScanResult> {
228        let state = self.state().read();
229        state.config_audit.clone()
230    }
231
232    // =========================================================
233    // Miscellaneous queries
234    // =========================================================
235
236    /// Match an agent to its definition by configured agent_type or member name.
237    fn match_agent_definition(
238        agent: &crate::agents::MonitoredAgent,
239        defs: &[crate::teams::AgentDefinition],
240    ) -> Option<AgentDefinitionInfo> {
241        if defs.is_empty() {
242            return None;
243        }
244        if let Some(ref team_info) = agent.team_info {
245            // 1) Try configured agent_type (explicit mapping from team config)
246            if let Some(ref agent_type) = team_info.agent_type {
247                if let Some(def) = defs.iter().find(|d| d.name == *agent_type) {
248                    return Some(AgentDefinitionInfo::from_definition(def));
249                }
250            }
251            // 2) Fallback: try member_name as agent definition name
252            if let Some(def) = defs.iter().find(|d| d.name == team_info.member_name) {
253                return Some(AgentDefinitionInfo::from_definition(def));
254            }
255        }
256        None
257    }
258
259    /// Check if the application is still running.
260    pub fn is_running(&self) -> bool {
261        let state = self.state().read();
262        state.running
263    }
264
265    /// Get the last poll timestamp.
266    pub fn last_poll(&self) -> Option<chrono::DateTime<chrono::Utc>> {
267        let state = self.state().read();
268        state.last_poll
269    }
270
271    /// Get known working directories from current agents.
272    pub fn known_directories(&self) -> Vec<String> {
273        let state = self.state().read();
274        state.get_known_directories()
275    }
276
277    // =========================================================
278    // Project queries
279    // =========================================================
280
281    /// List registered project directories.
282    pub fn list_projects(&self) -> Vec<String> {
283        let state = self.state().read();
284        state.registered_projects.clone()
285    }
286
287    /// Add a project directory. Persists to config.toml.
288    pub fn add_project(&self, path: &str) -> Result<(), ApiError> {
289        let canonical = std::path::Path::new(path);
290        if !canonical.is_absolute() {
291            return Err(ApiError::InvalidInput {
292                message: "Project path must be absolute".to_string(),
293            });
294        }
295        if !canonical.is_dir() {
296            return Err(ApiError::InvalidInput {
297                message: format!("Directory does not exist: {}", path),
298            });
299        }
300        let canonical_str = canonical.to_string_lossy().to_string();
301
302        let mut state = self.state().write();
303        if state.registered_projects.contains(&canonical_str) {
304            return Ok(()); // Already registered, idempotent
305        }
306        state.registered_projects.push(canonical_str);
307        let projects = state.registered_projects.clone();
308        drop(state);
309
310        crate::config::Settings::save_projects(&projects);
311        Ok(())
312    }
313
314    /// Remove a project directory. Persists to config.toml.
315    pub fn remove_project(&self, path: &str) -> Result<(), ApiError> {
316        let mut state = self.state().write();
317        let before = state.registered_projects.len();
318        state.registered_projects.retain(|p| p != path);
319        if state.registered_projects.len() == before {
320            return Err(ApiError::InvalidInput {
321                message: format!("Project not found: {}", path),
322            });
323        }
324        let projects = state.registered_projects.clone();
325        drop(state);
326
327        crate::config::Settings::save_projects(&projects);
328        Ok(())
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::agents::{AgentStatus, AgentType, MonitoredAgent};
336    use crate::api::builder::TmaiCoreBuilder;
337    use crate::config::Settings;
338    use crate::state::AppState;
339
340    fn make_core_with_agents(agents: Vec<MonitoredAgent>) -> TmaiCore {
341        let state = AppState::shared();
342        {
343            let mut s = state.write();
344            s.update_agents(agents);
345        }
346        TmaiCoreBuilder::new(Settings::default())
347            .with_state(state)
348            .build()
349    }
350
351    fn test_agent(id: &str, status: AgentStatus) -> MonitoredAgent {
352        let mut agent = MonitoredAgent::new(
353            id.to_string(),
354            AgentType::ClaudeCode,
355            "Title".to_string(),
356            "/home/user".to_string(),
357            100,
358            "main".to_string(),
359            "win".to_string(),
360            0,
361            0,
362        );
363        agent.status = status;
364        agent
365    }
366
367    #[test]
368    fn test_list_agents_empty() {
369        let core = TmaiCoreBuilder::new(Settings::default()).build();
370        assert!(core.list_agents().is_empty());
371    }
372
373    #[test]
374    fn test_list_agents() {
375        let core = make_core_with_agents(vec![
376            test_agent("main:0.0", AgentStatus::Idle),
377            test_agent(
378                "main:0.1",
379                AgentStatus::Processing {
380                    activity: "Bash".to_string(),
381                },
382            ),
383        ]);
384
385        let agents = core.list_agents();
386        assert_eq!(agents.len(), 2);
387    }
388
389    #[test]
390    fn test_get_agent_found() {
391        let core = make_core_with_agents(vec![test_agent("main:0.0", AgentStatus::Idle)]);
392
393        let result = core.get_agent("main:0.0");
394        assert!(result.is_ok());
395        assert_eq!(result.unwrap().id, "main:0.0");
396    }
397
398    #[test]
399    fn test_get_agent_not_found() {
400        let core = TmaiCoreBuilder::new(Settings::default()).build();
401        let result = core.get_agent("nonexistent");
402        assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
403    }
404
405    #[test]
406    fn test_attention_count() {
407        let core = make_core_with_agents(vec![
408            test_agent("main:0.0", AgentStatus::Idle),
409            test_agent(
410                "main:0.1",
411                AgentStatus::AwaitingApproval {
412                    approval_type: crate::agents::ApprovalType::ShellCommand,
413                    details: "rm -rf".to_string(),
414                },
415            ),
416            test_agent(
417                "main:0.2",
418                AgentStatus::Error {
419                    message: "oops".to_string(),
420                },
421            ),
422        ]);
423
424        assert_eq!(core.attention_count(), 2);
425        assert_eq!(core.agent_count(), 3);
426    }
427
428    #[test]
429    fn test_agents_needing_attention() {
430        let core = make_core_with_agents(vec![
431            test_agent("main:0.0", AgentStatus::Idle),
432            test_agent(
433                "main:0.1",
434                AgentStatus::AwaitingApproval {
435                    approval_type: crate::agents::ApprovalType::FileEdit,
436                    details: String::new(),
437                },
438            ),
439        ]);
440
441        let attention = core.agents_needing_attention();
442        assert_eq!(attention.len(), 1);
443        assert_eq!(attention[0].id, "main:0.1");
444    }
445
446    #[test]
447    fn test_get_preview() {
448        let mut agent = test_agent("main:0.0", AgentStatus::Idle);
449        agent.last_content_ansi = "\x1b[32mHello\x1b[0m".to_string();
450        agent.last_content = "Hello".to_string();
451
452        let core = make_core_with_agents(vec![agent]);
453
454        let preview = core.get_preview("main:0.0").unwrap();
455        assert!(preview.contains("Hello"));
456
457        let content = core.get_content("main:0.0").unwrap();
458        assert_eq!(content, "Hello");
459    }
460
461    #[test]
462    fn test_list_teams_empty() {
463        let core = TmaiCoreBuilder::new(Settings::default()).build();
464        assert!(core.list_teams().is_empty());
465    }
466
467    #[test]
468    fn test_is_running() {
469        let core = TmaiCoreBuilder::new(Settings::default()).build();
470        assert!(core.is_running());
471    }
472
473    #[test]
474    fn test_get_transcript_no_registry() {
475        // Without transcript registry, returns empty vec
476        let core = make_core_with_agents(vec![test_agent("main:0.0", AgentStatus::Idle)]);
477        let records = core.get_transcript("main:0.0").unwrap();
478        assert!(records.is_empty());
479    }
480
481    #[test]
482    fn test_get_transcript_agent_not_found() {
483        let core = TmaiCoreBuilder::new(Settings::default()).build();
484        let result = core.get_transcript("nonexistent");
485        assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
486    }
487
488    #[test]
489    fn test_get_transcript_with_registry() {
490        use crate::transcript::types::TranscriptRecord;
491        use crate::transcript::watcher::new_transcript_registry;
492
493        let registry = new_transcript_registry();
494        // Insert test records
495        {
496            let mut reg = registry.write();
497            let mut state = crate::transcript::TranscriptState::new(
498                "/tmp/test.jsonl".to_string(),
499                "sess1".to_string(),
500                "main:0.0".to_string(),
501            );
502            state.push_records(vec![
503                TranscriptRecord::User {
504                    text: "Hello".to_string(),
505                },
506                TranscriptRecord::AssistantText {
507                    text: "Hi there".to_string(),
508                },
509            ]);
510            reg.insert("main:0.0".to_string(), state);
511        }
512
513        let app_state = AppState::shared();
514        {
515            let mut s = app_state.write();
516            s.update_agents(vec![test_agent("main:0.0", AgentStatus::Idle)]);
517        }
518
519        let core = TmaiCoreBuilder::new(Settings::default())
520            .with_state(app_state)
521            .with_transcript_registry(registry)
522            .build();
523
524        let records = core.get_transcript("main:0.0").unwrap();
525        assert_eq!(records.len(), 2);
526    }
527}