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