Skip to main content

trustee_core/
sessions.rs

1//! Session discovery for the API/Web layer.
2//!
3//! Wraps `abk::checkpoint` to provide serializable session listing, detail,
4//! and resume-info creation — used by the Trustee REST API so users can
5//! browse and resume checkpoint sessions from the Web UI.
6
7use serde::{Deserialize, Serialize};
8
9use abk::checkpoint::{
10    get_storage_manager,
11    models::{CheckpointMetadata, ChatMessage, SessionMetadata as AbkSessionMetadata},
12    storage::ProjectMetadata as AbkProjectMetadata,
13    CheckpointStorageManager,
14};
15use abk::cli::ResumeInfo;
16
17/// Compact session info suitable for JSON API responses.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SessionSummary {
20    pub session_id: String,
21    pub project_name: String,
22    pub project_path: String,
23    /// Project hash (storage partition key) — used for per-user namespace filtering.
24    pub project_id: String,
25    pub checkpoint_count: usize,
26    pub created_at: chrono::DateTime<chrono::Utc>,
27    pub last_accessed: chrono::DateTime<chrono::Utc>,
28    pub description: Option<String>,
29    pub is_current_project: bool,
30}
31
32/// Compact checkpoint info for the session detail endpoint.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CheckpointSummary {
35    pub checkpoint_id: String,
36    pub session_id: String,
37    pub iteration: u32,
38    pub workflow_step: String,
39    pub created_at: chrono::DateTime<chrono::Utc>,
40}
41
42// -----------------------------------------------------------------------
43// Conversion helpers
44// -----------------------------------------------------------------------
45
46fn session_to_summary(
47    session: &AbkSessionMetadata,
48    project: &AbkProjectMetadata,
49    is_current: bool,
50) -> SessionSummary {
51    SessionSummary {
52        session_id: session.session_id.clone(),
53        project_name: project.name.clone(),
54        project_path: project.project_path.to_string_lossy().to_string(),
55        project_id: project.project_hash.clone(),
56        checkpoint_count: session.checkpoint_count as usize,
57        created_at: session.created_at,
58        last_accessed: session.last_accessed,
59        description: session.description.clone(),
60        is_current_project: is_current,
61    }
62}
63
64fn checkpoint_to_summary(cp: &CheckpointMetadata) -> CheckpointSummary {
65    CheckpointSummary {
66        checkpoint_id: cp.checkpoint_id.clone(),
67        session_id: cp.session_id.clone(),
68        iteration: cp.iteration,
69        workflow_step: format!("{:?}", cp.workflow_step),
70        created_at: cp.created_at,
71    }
72}
73
74/// Derive the working directory from a trustee config TOML string.
75///
76/// Looks for `[agent] working_dir`. Falls back to the current directory
77/// of the process if not specified or unparseable.
78fn config_working_dir(config_toml: &str) -> std::path::PathBuf {
79    if let Ok(value) = toml::from_str::<toml::Value>(config_toml) {
80        if let Some(agent) = value.get("agent") {
81            if let Some(wd) = agent.get("working_dir").and_then(|v| v.as_str()) {
82                return std::path::PathBuf::from(wd);
83            }
84        }
85    }
86    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
87}
88
89/// Check whether two paths refer to the same project by canonicalising.
90fn paths_match(a: &std::path::Path, b: &std::path::Path) -> bool {
91    let ca = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
92    let cb = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
93    ca == cb
94}
95
96/// Create a storage manager from an optional home_dir override.
97///
98/// When `home_dir` is `Some`, creates a per-user storage manager.
99/// When `None`, falls back to the global `get_storage_manager()`.
100fn make_storage_manager(
101    home_dir: Option<&std::path::Path>,
102    agent_name: &str,
103) -> anyhow::Result<CheckpointStorageManager> {
104    if let Some(dir) = home_dir {
105        CheckpointStorageManager::with_home_dir(dir.to_path_buf(), agent_name)
106            .map_err(|e| anyhow::anyhow!("Failed to create storage manager: {}", e))
107    } else {
108        get_storage_manager()
109            .map_err(|e| anyhow::anyhow!("Failed to get storage manager: {}", e))
110    }
111}
112
113// -----------------------------------------------------------------------
114// Public API
115// -----------------------------------------------------------------------
116
117/// List all sessions across all projects that have at least one checkpoint.
118///
119/// Sessions from the current project (derived from `config_toml`) are listed
120/// first, then everything else sorted by `last_accessed` descending.
121///
122/// When `home_dir` is `Some`, queries that specific user's checkpoint storage
123/// instead of the global default.
124pub async fn list_all_sessions(
125    config_toml: &str,
126    home_dir: Option<&std::path::Path>,
127) -> anyhow::Result<Vec<SessionSummary>> {
128    let current_dir = config_working_dir(config_toml);
129    let manager = make_storage_manager(home_dir, "trustee")?;
130
131    let projects = manager
132        .list_projects()
133        .await
134        .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
135
136    let mut summaries = Vec::new();
137
138    for project in &projects {
139        // Skip projects whose path can't be resolved (e.g. deleted dirs,
140        // permission denied) — don't let one bad project kill the whole list.
141        let project_storage = match manager.get_project_storage_with_id(&project.project_path, &project.project_hash).await {
142            Ok(ps) => ps,
143            Err(e) => {
144                tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
145                continue;
146            }
147        };
148
149        let sessions = match project_storage.list_sessions().await {
150            Ok(s) => s,
151            Err(e) => {
152                tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
153                continue;
154            }
155        };
156
157        let is_current = paths_match(&project.project_path, &current_dir);
158
159        for session in sessions {
160            // Only include sessions that have checkpoints (resumable)
161            if session.checkpoint_count > 0 {
162                summaries.push(session_to_summary(&session, project, is_current));
163            }
164        }
165    }
166
167    // Sort: current project first, then by last_accessed descending
168    summaries.sort_by(|a, b| match (a.is_current_project, b.is_current_project) {
169        (true, false) => std::cmp::Ordering::Less,
170        (false, true) => std::cmp::Ordering::Greater,
171        _ => b.last_accessed.cmp(&a.last_accessed),
172    });
173
174    Ok(summaries)
175}
176
177/// Get detailed information about a specific session, including its checkpoints.
178///
179/// Searches all projects for the given `session_id`.
180/// Returns `None` if the session is not found.
181///
182/// When `home_dir` is `Some`, searches that specific user's checkpoint storage.
183pub async fn get_session_detail(
184    _config_toml: &str,
185    session_id: &str,
186    home_dir: Option<&std::path::Path>,
187) -> anyhow::Result<Option<(SessionSummary, Vec<CheckpointSummary>)>> {
188    let manager = make_storage_manager(home_dir, "trustee")?;
189
190    let projects = manager
191        .list_projects()
192        .await
193        .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
194
195    for project in &projects {
196        let project_storage = match manager.get_project_storage_with_id(&project.project_path, &project.project_hash).await {
197            Ok(ps) => ps,
198            Err(e) => {
199                tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
200                continue;
201            }
202        };
203
204        let sessions = match project_storage.list_sessions().await {
205            Ok(s) => s,
206            Err(e) => {
207                tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
208                continue;
209            }
210        };
211
212        if let Some(session) = sessions.iter().find(|s| s.session_id == session_id) {
213            let session_storage = project_storage
214                .create_session(session_id)
215                .await
216                .map_err(|e| anyhow::anyhow!("Failed to get session storage: {}", e))?;
217
218            let checkpoints = session_storage
219                .list_checkpoints()
220                .await
221                .map_err(|e| anyhow::anyhow!("Failed to list checkpoints: {}", e))?;
222
223            let summary = session_to_summary(session, project, false);
224            let cp_summaries: Vec<CheckpointSummary> =
225                checkpoints.iter().map(checkpoint_to_summary).collect();
226
227            return Ok(Some((summary, cp_summaries)));
228        }
229    }
230
231    Ok(None)
232}
233
234/// Create a `ResumeInfo` from the latest checkpoint of a given session.
235///
236/// Searches all projects for `session_id`, finds the most recent checkpoint,
237/// and returns a `ResumeInfo` suitable for setting on `Session::resume_info`.
238/// Returns `None` if the session or its checkpoints are not found.
239///
240/// When `home_dir` is `Some`, searches that specific user's checkpoint storage.
241pub async fn create_resume_info(
242    _config_toml: &str,
243    session_id: &str,
244    home_dir: Option<&std::path::Path>,
245) -> anyhow::Result<Option<ResumeInfo>> {
246    let manager = make_storage_manager(home_dir, "trustee")?;
247
248    let projects = manager
249        .list_projects()
250        .await
251        .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
252
253    for project in &projects {
254        let project_storage = match manager.get_project_storage_with_id(&project.project_path, &project.project_hash).await {
255            Ok(ps) => ps,
256            Err(e) => {
257                tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
258                continue;
259            }
260        };
261
262        let sessions = match project_storage.list_sessions().await {
263            Ok(s) => s,
264            Err(e) => {
265                tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
266                continue;
267            }
268        };
269
270        if sessions.iter().any(|s| s.session_id == session_id) {
271            let session_storage = project_storage
272                .create_session(session_id)
273                .await
274                .map_err(|e| anyhow::anyhow!("Failed to get session storage: {}", e))?;
275
276            let checkpoints = session_storage
277                .list_checkpoints()
278                .await
279                .map_err(|e| anyhow::anyhow!("Failed to list checkpoints: {}", e))?;
280
281            if let Some(latest) = checkpoints.iter().max_by_key(|cp| cp.created_at) {
282                return Ok(Some(ResumeInfo {
283                    session_id: session_id.to_string(),
284                    checkpoint_id: latest.checkpoint_id.clone(),
285                    iteration: latest.iteration,
286                    project_path: Some(project.project_path.clone()),
287                }));
288            }
289
290            // Session found but no checkpoints
291            return Ok(None);
292        }
293    }
294
295    Ok(None)
296}
297
298// -----------------------------------------------------------------------
299// Conversation history loading
300// -----------------------------------------------------------------------
301
302/// A single chat message rendered in the Web UI conversation history.
303///
304/// Each message maps to how the TUI/Web already renders things:
305/// - `user` messages → right-aligned chat bubble
306/// - `assistant` messages → left-aligned agent bubble (markdown)
307/// - `assistant` with `tool_calls` → tool-pending/tool-done lines
308/// - `tool` messages → hidden (tool results are shown via tool_calls)
309/// - `reasoning` → collapsible reasoning section
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct HistoryMessage {
312    /// "user", "assistant", or "tool"
313    pub role: String,
314    /// Main message text
315    pub content: String,
316    /// Reasoning/thinking content (if any)
317    pub reasoning: Option<String>,
318    /// Tool calls (assistant messages that invoke tools)
319    pub tool_calls: Option<Vec<HistoryToolCall>>,
320    /// Tool name (for tool-role messages)
321    pub name: Option<String>,
322}
323
324/// A tool call within a message.
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct HistoryToolCall {
327    pub name: String,
328    /// Short description of what the tool was called with (for display)
329    pub hint: String,
330}
331
332/// Metadata about the session/task for the history header.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct SessionHistory {
335    pub session_id: String,
336    pub checkpoint_id: String,
337    pub task_description: String,
338    pub iteration: u32,
339    pub total_messages: usize,
340    pub messages: Vec<HistoryMessage>,
341}
342
343/// Load conversation history from a session's latest checkpoint.
344///
345/// Returns messages suitable for rendering in the Web UI. System messages
346/// are filtered out, tool results are summarized, and reasoning is
347/// preserved separately.
348///
349/// When `home_dir` is `Some`, loads from that specific user's checkpoint storage.
350pub async fn load_session_history(
351    session_id: &str,
352    home_dir: Option<&std::path::Path>,
353) -> anyhow::Result<Option<SessionHistory>> {
354    let manager = make_storage_manager(home_dir, "trustee")?;;
355
356    let projects = manager
357        .list_projects()
358        .await
359        .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
360
361    for project in &projects {
362        let project_storage = match manager.get_project_storage_with_id(&project.project_path, &project.project_hash).await {
363            Ok(ps) => ps,
364            Err(e) => {
365                tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
366                continue;
367            }
368        };
369
370        let sessions = match project_storage.list_sessions().await {
371            Ok(s) => s,
372            Err(e) => {
373                tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
374                continue;
375            }
376        };
377
378        if !sessions.iter().any(|s| s.session_id == session_id) {
379            continue;
380        }
381
382        let session_storage = project_storage
383            .create_session(session_id)
384            .await
385            .map_err(|e| anyhow::anyhow!("Failed to get session storage: {}", e))?;
386
387        let checkpoints = session_storage
388            .list_checkpoints()
389            .await
390            .map_err(|e| anyhow::anyhow!("Failed to list checkpoints: {}", e))?;
391
392        let latest = match checkpoints.iter().max_by_key(|cp| cp.created_at) {
393            Some(cp) => cp,
394            None => return Ok(None),
395        };
396
397        let checkpoint_id = latest.checkpoint_id.clone();
398        let iteration = latest.iteration;
399
400        let checkpoint = session_storage
401            .load_checkpoint(&checkpoint_id)
402            .await
403            .map_err(|e| anyhow::anyhow!("Failed to load checkpoint: {}", e))?;
404
405        let task_description = checkpoint.agent_state.task_description.clone();
406        let total_messages = checkpoint.conversation_state.messages.len();
407
408        let messages = convert_messages(&checkpoint.conversation_state.messages);
409
410        return Ok(Some(SessionHistory {
411            session_id: session_id.to_string(),
412            checkpoint_id,
413            task_description,
414            iteration,
415            total_messages,
416            messages,
417        }));
418    }
419
420    Ok(None)
421}
422
423/// Convert ABK `ChatMessage`s to Web UI-friendly `HistoryMessage`s.
424///
425/// - System messages are dropped (not useful in UI)
426/// - Tool-role messages are dropped (tool results shown via assistant's tool_calls)
427/// - Very long content is truncated to avoid massive payloads
428fn convert_messages(messages: &[ChatMessage]) -> Vec<HistoryMessage> {
429    const MAX_CONTENT_LEN: usize = 10_000;
430
431    let mut result = Vec::new();
432
433    for msg in messages {
434        // Skip system messages — not useful in the conversation view
435        if msg.role == "system" {
436            continue;
437        }
438
439        // Skip tool-role messages — tool results are shown via the
440        // assistant's tool_calls and a compact tool-done line
441        if msg.role == "tool" {
442            continue;
443        }
444
445        let content = if msg.content.len() > MAX_CONTENT_LEN {
446            format!("{}...\n[truncated]", &msg.content[..MAX_CONTENT_LEN])
447        } else {
448            msg.content.clone()
449        };
450
451        // Convert tool calls if present
452        let tool_calls = msg.tool_calls.as_ref().map(|calls| {
453            calls
454                .iter()
455                .map(|tc| {
456                    let hint = summarize_tool_args(&tc.function.name, &tc.function.arguments);
457                    HistoryToolCall {
458                        name: tc.function.name.clone(),
459                        hint,
460                    }
461                })
462                .collect::<Vec<_>>()
463        });
464
465        // For assistant messages that only contain tool calls (empty content),
466        // we still emit them so tool call lines render
467        if msg.role == "assistant" && content.is_empty() && tool_calls.is_some() {
468            result.push(HistoryMessage {
469                role: "assistant".to_string(),
470                content: String::new(),
471                reasoning: msg.reasoning.clone(),
472                tool_calls,
473                name: None,
474            });
475            continue;
476        }
477
478        result.push(HistoryMessage {
479            role: msg.role.clone(),
480            content,
481            reasoning: msg.reasoning.clone(),
482            tool_calls,
483            name: msg.name.clone(),
484        });
485    }
486
487    result
488}
489
490/// Create a short human-readable hint from tool call arguments.
491///
492/// e.g. `{"command": "ls -la /tmp"}` → `ls -la /tmp`
493///      `{"file_path": "/foo/bar.rs"}` → `/foo/bar.rs`
494fn summarize_tool_args(name: &str, args: &str) -> String {
495    let parsed: serde_json::Value = match serde_json::from_str(args) {
496        Ok(v) => v,
497        Err(_) => return args.chars().take(200).collect(),
498    };
499
500    let obj = match parsed.as_object() {
501        Some(o) => o,
502        None => return args.chars().take(200).collect(),
503    };
504
505    match name {
506        "bash" | "execute_command" => {
507            obj.get("command").and_then(|v| v.as_str()).map(|s| s.to_string())
508        }
509        "read" | "read_file" => {
510            obj.get("file_path").and_then(|v| v.as_str()).map(|s| s.to_string())
511        }
512        "write" | "write_file" | "edit" => {
513            obj.get("file_path").and_then(|v| v.as_str()).map(|s| s.to_string())
514        }
515        "grep" | "search" => {
516            obj.get("pattern").and_then(|v| v.as_str()).map(|s| s.to_string())
517        }
518        "glob" => {
519            obj.get("pattern").and_then(|v| v.as_str()).map(|s| s.to_string())
520        }
521        "todowrite" => {
522            let count = obj.get("todos").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
523            Some(format!("{} items", count))
524        }
525        "websearch" | "webfetch" => {
526            obj.get("query").or_else(|| obj.get("url")).and_then(|v| v.as_str()).map(|s| s.to_string())
527        }
528        _ => {
529            obj.values().next().and_then(|v| match v {
530                serde_json::Value::String(s) => Some(s.clone()),
531                _ => Some(v.to_string()),
532            })
533        }
534    }
535    .unwrap_or_default()
536    .chars()
537    .take(200)
538    .collect()
539}