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()`.
100///
101/// When `config_toml` contains a `[checkpointing.storage_backend]` section
102/// with a DocumentDB/MongoDB backend, the remote backend is initialized
103/// alongside the per-user home_dir.
104async fn make_storage_manager(
105    config_toml: &str,
106    home_dir: Option<&std::path::Path>,
107    agent_name: &str,
108) -> anyhow::Result<CheckpointStorageManager> {
109    // Try to parse storage_backend config from the TOML
110    let backend_config = parse_storage_backend_config(config_toml);
111
112    match (home_dir, backend_config) {
113        // Per-user home_dir + remote backend → use the merged constructor (async)
114        (Some(dir), Some(backend)) => {
115            CheckpointStorageManager::with_home_dir_and_backend(
116                dir.to_path_buf(),
117                agent_name,
118                backend,
119            )
120            .await
121            .map_err(|e| anyhow::anyhow!("Failed to create storage manager with backend: {}", e))
122        }
123        // Per-user home_dir, no remote backend → local-only per-user storage
124        (Some(dir), None) => {
125            CheckpointStorageManager::with_home_dir(dir.to_path_buf(), agent_name)
126                .map_err(|e| anyhow::anyhow!("Failed to create storage manager: {}", e))
127        }
128        // No home_dir, fall back to global
129        (None, _) => {
130            get_storage_manager()
131                .map_err(|e| anyhow::anyhow!("Failed to get storage manager: {}", e))
132        }
133    }
134}
135
136/// Parse `[checkpointing.storage_backend]` from a trustee config TOML string.
137///
138/// Returns `None` if the section doesn't exist or if the backend type is `File`.
139fn parse_storage_backend_config(config_toml: &str) -> Option<abk::checkpoint::StorageBackendConfig> {
140    let value = toml::from_str::<toml::Value>(config_toml).ok()?;
141    let checkpointing = value.get("checkpointing")?;
142    let storage_backend = checkpointing.get("storage_backend")?;
143    let backend: abk::checkpoint::StorageBackendConfig =
144        storage_backend.clone().try_into().ok()?;
145
146    // Only return if it's actually a remote backend
147    match backend.backend_type {
148        abk::checkpoint::StorageBackendType::DocumentDB
149        | abk::checkpoint::StorageBackendType::MongoDB => Some(backend),
150        abk::checkpoint::StorageBackendType::File => None,
151    }
152}
153
154// -----------------------------------------------------------------------
155// Public API
156// -----------------------------------------------------------------------
157
158/// List all sessions across all projects that have at least one checkpoint.
159///
160/// Sessions from the current project (derived from `config_toml`) are listed
161/// first, then everything else sorted by `last_accessed` descending.
162///
163/// When `home_dir` is `Some`, queries that specific user's checkpoint storage
164/// instead of the global default.
165pub async fn list_all_sessions(
166    config_toml: &str,
167    home_dir: Option<&std::path::Path>,
168) -> anyhow::Result<Vec<SessionSummary>> {
169    let current_dir = config_working_dir(config_toml);
170    let manager = make_storage_manager(config_toml, home_dir, "trustee").await?;
171
172    let projects = manager
173        .list_projects()
174        .await
175        .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
176
177    let mut summaries = Vec::new();
178
179    for project in &projects {
180        // Skip projects whose path can't be resolved (e.g. deleted dirs,
181        // permission denied) — don't let one bad project kill the whole list.
182        let project_storage = match manager.get_project_storage_with_id(&project.project_path, &project.project_hash).await {
183            Ok(ps) => ps,
184            Err(e) => {
185                tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
186                continue;
187            }
188        };
189
190        let sessions = match project_storage.list_sessions().await {
191            Ok(s) => s,
192            Err(e) => {
193                tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
194                continue;
195            }
196        };
197
198        let is_current = paths_match(&project.project_path, &current_dir);
199
200        for session in sessions {
201            // Only include sessions that have checkpoints (resumable)
202            if session.checkpoint_count > 0 {
203                summaries.push(session_to_summary(&session, project, is_current));
204            }
205        }
206    }
207
208    // Sort: current project first, then by last_accessed descending
209    summaries.sort_by(|a, b| match (a.is_current_project, b.is_current_project) {
210        (true, false) => std::cmp::Ordering::Less,
211        (false, true) => std::cmp::Ordering::Greater,
212        _ => b.last_accessed.cmp(&a.last_accessed),
213    });
214
215    Ok(summaries)
216}
217
218/// Get detailed information about a specific session, including its checkpoints.
219///
220/// Searches all projects for the given `session_id`.
221/// Returns `None` if the session is not found.
222///
223/// When `home_dir` is `Some`, searches that specific user's checkpoint storage.
224pub async fn get_session_detail(
225    _config_toml: &str,
226    session_id: &str,
227    home_dir: Option<&std::path::Path>,
228) -> anyhow::Result<Option<(SessionSummary, Vec<CheckpointSummary>)>> {
229    let manager = make_storage_manager(_config_toml, home_dir, "trustee").await?;
230
231    let projects = manager
232        .list_projects()
233        .await
234        .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
235
236    for project in &projects {
237        let project_storage = match manager.get_project_storage_with_id(&project.project_path, &project.project_hash).await {
238            Ok(ps) => ps,
239            Err(e) => {
240                tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
241                continue;
242            }
243        };
244
245        let sessions = match project_storage.list_sessions().await {
246            Ok(s) => s,
247            Err(e) => {
248                tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
249                continue;
250            }
251        };
252
253        if let Some(session) = sessions.iter().find(|s| s.session_id == session_id) {
254            let session_storage = project_storage
255                .create_session(session_id)
256                .await
257                .map_err(|e| anyhow::anyhow!("Failed to get session storage: {}", e))?;
258
259            let checkpoints = session_storage
260                .list_checkpoints()
261                .await
262                .map_err(|e| anyhow::anyhow!("Failed to list checkpoints: {}", e))?;
263
264            let summary = session_to_summary(session, project, false);
265            let cp_summaries: Vec<CheckpointSummary> =
266                checkpoints.iter().map(checkpoint_to_summary).collect();
267
268            return Ok(Some((summary, cp_summaries)));
269        }
270    }
271
272    Ok(None)
273}
274
275/// Create a `ResumeInfo` from the latest checkpoint of a given session.
276///
277/// Searches all projects for `session_id`, finds the most recent checkpoint,
278/// and returns a `ResumeInfo` suitable for setting on `Session::resume_info`.
279/// Returns `None` if the session or its checkpoints are not found.
280///
281/// When `home_dir` is `Some`, searches that specific user's checkpoint storage.
282pub async fn create_resume_info(
283    _config_toml: &str,
284    session_id: &str,
285    home_dir: Option<&std::path::Path>,
286) -> anyhow::Result<Option<ResumeInfo>> {
287    let manager = make_storage_manager(_config_toml, home_dir, "trustee").await?;
288
289    let projects = manager
290        .list_projects()
291        .await
292        .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
293
294    for project in &projects {
295        let project_storage = match manager.get_project_storage_with_id(&project.project_path, &project.project_hash).await {
296            Ok(ps) => ps,
297            Err(e) => {
298                tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
299                continue;
300            }
301        };
302
303        let sessions = match project_storage.list_sessions().await {
304            Ok(s) => s,
305            Err(e) => {
306                tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
307                continue;
308            }
309        };
310
311        if sessions.iter().any(|s| s.session_id == session_id) {
312            let session_storage = project_storage
313                .create_session(session_id)
314                .await
315                .map_err(|e| anyhow::anyhow!("Failed to get session storage: {}", e))?;
316
317            let checkpoints = session_storage
318                .list_checkpoints()
319                .await
320                .map_err(|e| anyhow::anyhow!("Failed to list checkpoints: {}", e))?;
321
322            if let Some(latest) = checkpoints.iter().max_by_key(|cp| cp.created_at) {
323                return Ok(Some(ResumeInfo {
324                    session_id: session_id.to_string(),
325                    checkpoint_id: latest.checkpoint_id.clone(),
326                    iteration: latest.iteration,
327                    project_path: Some(project.project_path.clone()),
328                }));
329            }
330
331            // Session found but no checkpoints
332            return Ok(None);
333        }
334    }
335
336    Ok(None)
337}
338
339// -----------------------------------------------------------------------
340// Conversation history loading
341// -----------------------------------------------------------------------
342
343/// A single chat message rendered in the Web UI conversation history.
344///
345/// Each message maps to how the TUI/Web already renders things:
346/// - `user` messages → right-aligned chat bubble
347/// - `assistant` messages → left-aligned agent bubble (markdown)
348/// - `assistant` with `tool_calls` → tool-pending/tool-done lines
349/// - `tool` messages → hidden (tool results are shown via tool_calls)
350/// - `reasoning` → collapsible reasoning section
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct HistoryMessage {
353    /// "user", "assistant", or "tool"
354    pub role: String,
355    /// Main message text
356    pub content: String,
357    /// Reasoning/thinking content (if any)
358    pub reasoning: Option<String>,
359    /// Tool calls (assistant messages that invoke tools)
360    pub tool_calls: Option<Vec<HistoryToolCall>>,
361    /// Tool name (for tool-role messages)
362    pub name: Option<String>,
363}
364
365/// A tool call within a message.
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct HistoryToolCall {
368    pub name: String,
369    /// Short description of what the tool was called with (for display)
370    pub hint: String,
371}
372
373/// Metadata about the session/task for the history header.
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct SessionHistory {
376    pub session_id: String,
377    pub checkpoint_id: String,
378    pub task_description: String,
379    pub iteration: u32,
380    pub total_messages: usize,
381    pub messages: Vec<HistoryMessage>,
382}
383
384/// Load conversation history from a session's latest checkpoint.
385///
386/// Returns messages suitable for rendering in the Web UI. System messages
387/// are filtered out, tool results are summarized, and reasoning is
388/// preserved separately.
389///
390/// When `home_dir` is `Some`, loads from that specific user's checkpoint storage.
391pub async fn load_session_history(
392    session_id: &str,
393    home_dir: Option<&std::path::Path>,
394) -> anyhow::Result<Option<SessionHistory>> {
395    let manager = make_storage_manager("", home_dir, "trustee").await?;
396
397    let projects = manager
398        .list_projects()
399        .await
400        .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
401
402    for project in &projects {
403        let project_storage = match manager.get_project_storage_with_id(&project.project_path, &project.project_hash).await {
404            Ok(ps) => ps,
405            Err(e) => {
406                tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
407                continue;
408            }
409        };
410
411        let sessions = match project_storage.list_sessions().await {
412            Ok(s) => s,
413            Err(e) => {
414                tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
415                continue;
416            }
417        };
418
419        if !sessions.iter().any(|s| s.session_id == session_id) {
420            continue;
421        }
422
423        let session_storage = project_storage
424            .create_session(session_id)
425            .await
426            .map_err(|e| anyhow::anyhow!("Failed to get session storage: {}", e))?;
427
428        let checkpoints = session_storage
429            .list_checkpoints()
430            .await
431            .map_err(|e| anyhow::anyhow!("Failed to list checkpoints: {}", e))?;
432
433        let latest = match checkpoints.iter().max_by_key(|cp| cp.created_at) {
434            Some(cp) => cp,
435            None => return Ok(None),
436        };
437
438        let checkpoint_id = latest.checkpoint_id.clone();
439        let iteration = latest.iteration;
440
441        let checkpoint = session_storage
442            .load_checkpoint(&checkpoint_id)
443            .await
444            .map_err(|e| anyhow::anyhow!("Failed to load checkpoint: {}", e))?;
445
446        let task_description = checkpoint.agent_state.task_description.clone();
447        let total_messages = checkpoint.conversation_state.messages.len();
448
449        let messages = convert_messages(&checkpoint.conversation_state.messages);
450
451        return Ok(Some(SessionHistory {
452            session_id: session_id.to_string(),
453            checkpoint_id,
454            task_description,
455            iteration,
456            total_messages,
457            messages,
458        }));
459    }
460
461    Ok(None)
462}
463
464/// Convert ABK `ChatMessage`s to Web UI-friendly `HistoryMessage`s.
465///
466/// - System messages are dropped (not useful in UI)
467/// - Tool-role messages are dropped (tool results shown via assistant's tool_calls)
468/// - Very long content is truncated to avoid massive payloads
469fn convert_messages(messages: &[ChatMessage]) -> Vec<HistoryMessage> {
470    const MAX_CONTENT_LEN: usize = 10_000;
471
472    let mut result = Vec::new();
473
474    for msg in messages {
475        // Skip system messages — not useful in the conversation view
476        if msg.role == "system" {
477            continue;
478        }
479
480        // Skip tool-role messages — tool results are shown via the
481        // assistant's tool_calls and a compact tool-done line
482        if msg.role == "tool" {
483            continue;
484        }
485
486        let content = if msg.content.len() > MAX_CONTENT_LEN {
487            format!("{}...\n[truncated]", &msg.content[..MAX_CONTENT_LEN])
488        } else {
489            msg.content.clone()
490        };
491
492        // Convert tool calls if present
493        let tool_calls = msg.tool_calls.as_ref().map(|calls| {
494            calls
495                .iter()
496                .map(|tc| {
497                    let hint = summarize_tool_args(&tc.function.name, &tc.function.arguments);
498                    HistoryToolCall {
499                        name: tc.function.name.clone(),
500                        hint,
501                    }
502                })
503                .collect::<Vec<_>>()
504        });
505
506        // For assistant messages that only contain tool calls (empty content),
507        // we still emit them so tool call lines render
508        if msg.role == "assistant" && content.is_empty() && tool_calls.is_some() {
509            result.push(HistoryMessage {
510                role: "assistant".to_string(),
511                content: String::new(),
512                reasoning: msg.reasoning.clone(),
513                tool_calls,
514                name: None,
515            });
516            continue;
517        }
518
519        result.push(HistoryMessage {
520            role: msg.role.clone(),
521            content,
522            reasoning: msg.reasoning.clone(),
523            tool_calls,
524            name: msg.name.clone(),
525        });
526    }
527
528    result
529}
530
531/// Create a short human-readable hint from tool call arguments.
532///
533/// e.g. `{"command": "ls -la /tmp"}` → `ls -la /tmp`
534///      `{"file_path": "/foo/bar.rs"}` → `/foo/bar.rs`
535fn summarize_tool_args(name: &str, args: &str) -> String {
536    let parsed: serde_json::Value = match serde_json::from_str(args) {
537        Ok(v) => v,
538        Err(_) => return args.chars().take(200).collect(),
539    };
540
541    let obj = match parsed.as_object() {
542        Some(o) => o,
543        None => return args.chars().take(200).collect(),
544    };
545
546    match name {
547        "bash" | "execute_command" => {
548            obj.get("command").and_then(|v| v.as_str()).map(|s| s.to_string())
549        }
550        "read" | "read_file" => {
551            obj.get("file_path").and_then(|v| v.as_str()).map(|s| s.to_string())
552        }
553        "write" | "write_file" | "edit" => {
554            obj.get("file_path").and_then(|v| v.as_str()).map(|s| s.to_string())
555        }
556        "grep" | "search" => {
557            obj.get("pattern").and_then(|v| v.as_str()).map(|s| s.to_string())
558        }
559        "glob" => {
560            obj.get("pattern").and_then(|v| v.as_str()).map(|s| s.to_string())
561        }
562        "todowrite" => {
563            let count = obj.get("todos").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
564            Some(format!("{} items", count))
565        }
566        "websearch" | "webfetch" => {
567            obj.get("query").or_else(|| obj.get("url")).and_then(|v| v.as_str()).map(|s| s.to_string())
568        }
569        _ => {
570            obj.values().next().and_then(|v| match v {
571                serde_json::Value::String(s) => Some(s.clone()),
572                _ => Some(v.to_string()),
573            })
574        }
575    }
576    .unwrap_or_default()
577    .chars()
578    .take(200)
579    .collect()
580}