// SPDX-License-Identifier: LicenseRef-Commercial // Session Export & Reporting — generate Markdown reports from agent sessions. use serde::Serialize; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SessionReport { pub project_id: String, pub session_id: String, pub markdown: String, pub cost_usd: f64, pub turn_count: i64, pub tool_call_count: i64, pub duration_minutes: f64, pub model: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProjectSummaryReport { pub project_id: String, pub markdown: String, pub total_sessions: i64, pub total_cost_usd: f64, pub period_days: i64, } #[tauri::command] pub fn pro_export_session(project_id: String, session_id: String) -> Result { let conn = super::open_sessions_db()?; // Get metric for this session let mut stmt = conn.prepare( "SELECT start_time, end_time, peak_tokens, turn_count, tool_call_count, cost_usd, model, status, error_message FROM session_metrics WHERE project_id = ?1 AND session_id = ?2" ).map_err(|e| format!("Query failed: {e}"))?; let (start, end, tokens, turns, tools, cost, model, status, error): (i64, i64, i64, i64, i64, f64, Option, String, Option) = stmt.query_row(rusqlite::params![project_id, session_id], |row| { Ok((row.get("start_time")?, row.get("end_time")?, row.get("peak_tokens")?, row.get("turn_count")?, row.get("tool_call_count")?, row.get("cost_usd")?, row.get("model")?, row.get("status")?, row.get("error_message")?)) }).map_err(|e| format!("Session not found: {e}"))?; let model_name = model.clone().unwrap_or_else(|| "unknown".into()); let duration_min = (end - start) as f64 / 60.0; let start_str = epoch_to_iso(start); let end_str = epoch_to_iso(end); // Get messages for this session let mut msg_stmt = conn.prepare( "SELECT message_type, content, created_at FROM agent_messages WHERE session_id = ?1 ORDER BY created_at ASC" ).map_err(|e| format!("Messages query failed: {e}"))?; let messages: Vec<(String, String, i64)> = msg_stmt .query_map(rusqlite::params![session_id], |row| { Ok((row.get("message_type")?, row.get("content")?, row.get("created_at")?)) }) .map_err(|e| format!("Messages query failed: {e}"))? .collect::, _>>() .map_err(|e| format!("Row read failed: {e}"))?; let mut md = String::new(); md.push_str(&format!("# Session Report: {session_id}\n\n")); md.push_str(&format!("**Project:** {project_id} \n")); md.push_str(&format!("**Model:** {model_name} \n")); md.push_str(&format!("**Status:** {status} \n")); md.push_str(&format!("**Duration:** {duration_min:.1} min \n")); md.push_str(&format!("**Start:** {start_str} \n")); md.push_str(&format!("**End:** {end_str} \n\n")); md.push_str("## Metrics\n\n"); md.push_str(&format!("| Metric | Value |\n|--------|-------|\n")); md.push_str(&format!("| Cost | ${cost:.4} |\n")); md.push_str(&format!("| Peak Tokens | {tokens} |\n")); md.push_str(&format!("| Turns | {turns} |\n")); md.push_str(&format!("| Tool Calls | {tools} |\n\n")); if let Some(err) = &error { md.push_str(&format!("## Error\n\n```\n{err}\n```\n\n")); } if !messages.is_empty() { md.push_str("## Conversation\n\n"); for (msg_type, content, ts) in &messages { let time = epoch_to_time(*ts); let prefix = match msg_type.as_str() { "user" | "human" => "**User**", "assistant" => "**Assistant**", "tool_call" => "**Tool Call**", "tool_result" => "**Tool Result**", _ => msg_type.as_str(), }; // Truncate long content (safe UTF-8 boundary) let display = if content.len() > 500 { let truncated: String = content.chars().take(500).collect(); format!("{}... *(truncated, {} chars)*", truncated, content.len()) } else { content.clone() }; md.push_str(&format!("### [{time}] {prefix}\n\n{display}\n\n")); } } Ok(SessionReport { project_id, session_id, markdown: md, cost_usd: cost, turn_count: turns, tool_call_count: tools, duration_minutes: duration_min, model: model_name, }) } #[tauri::command] pub fn pro_export_project_summary(project_id: String, days: Option) -> Result { let conn = super::open_sessions_db()?; let period = days.unwrap_or(30); let cutoff = super::analytics::now_epoch() - (period * 86400); let mut stmt = conn.prepare( "SELECT session_id, start_time, end_time, cost_usd, turn_count, tool_call_count, model, status FROM session_metrics WHERE project_id = ?1 AND end_time >= ?2 ORDER BY end_time DESC" ).map_err(|e| format!("Query failed: {e}"))?; let sessions: Vec<(String, i64, i64, f64, i64, i64, Option, String)> = stmt .query_map(rusqlite::params![project_id, cutoff], |row| { Ok((row.get("session_id")?, row.get("start_time")?, row.get("end_time")?, row.get("cost_usd")?, row.get("turn_count")?, row.get("tool_call_count")?, row.get("model")?, row.get("status")?)) }) .map_err(|e| format!("Query failed: {e}"))? .collect::, _>>() .map_err(|e| format!("Row read failed: {e}"))?; let total_cost: f64 = sessions.iter().map(|s| s.3).sum(); let total_sessions = sessions.len() as i64; let mut md = String::new(); md.push_str(&format!("# Project Summary: {project_id}\n\n")); md.push_str(&format!("**Period:** last {period} days \n")); md.push_str(&format!("**Total Sessions:** {total_sessions} \n")); md.push_str(&format!("**Total Cost:** ${total_cost:.4} \n\n")); if !sessions.is_empty() { md.push_str("## Sessions\n\n"); md.push_str("| Date | Session | Model | Duration | Cost | Turns | Tools | Status |\n"); md.push_str("|------|---------|-------|----------|------|-------|-------|--------|\n"); for (sid, start, end, cost, turns, tools, model, status) in &sessions { let date = epoch_to_date(*start); let dur = (*end - *start) as f64 / 60.0; let m = model.as_deref().unwrap_or("?"); let short_sid = if sid.len() > 8 { &sid[..8] } else { sid }; md.push_str(&format!("| {date} | {short_sid}.. | {m} | {dur:.0}m | ${cost:.4} | {turns} | {tools} | {status} |\n")); } } Ok(ProjectSummaryReport { project_id, markdown: md, total_sessions, total_cost_usd: total_cost, period_days: period, }) } fn epoch_to_iso(epoch: i64) -> String { let secs = epoch; let time = secs % 86400; let h = time / 3600; let m = (time % 3600) / 60; // Simple date from epoch (2000-01-01 = day 10957 from 1970) format!("{}T{:02}:{:02}Z", epoch_to_date(secs), h, m) } fn epoch_to_time(epoch: i64) -> String { let time = epoch % 86400; format!("{:02}:{:02}", time / 3600, (time % 3600) / 60) } pub(crate) fn epoch_to_date(epoch: i64) -> String { // Days since 1970-01-01 let mut days = epoch / 86400; let mut year = 1970i64; loop { let days_in_year = if is_leap(year) { 366 } else { 365 }; if days < days_in_year { break; } days -= days_in_year; year += 1; } let months = if is_leap(year) { [31,29,31,30,31,30,31,31,30,31,30,31] } else { [31,28,31,30,31,30,31,31,30,31,30,31] }; let mut month = 0usize; for (i, &d) in months.iter().enumerate() { if days < d { month = i; break; } days -= d; } format!("{year:04}-{:02}-{:02}", month + 1, days + 1) } fn is_leap(y: i64) -> bool { (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 } #[cfg(test)] mod tests { use super::*; #[test] fn test_epoch_to_date() { assert_eq!(epoch_to_date(0), "1970-01-01"); assert_eq!(epoch_to_date(1710000000), "2024-03-09"); } #[test] fn test_epoch_to_iso() { let iso = epoch_to_iso(1710000000); assert!(iso.starts_with("2024-")); assert!(iso.ends_with('Z')); } #[test] fn test_session_report_struct() { let r = SessionReport { project_id: "test".into(), session_id: "abc".into(), markdown: "# Report".into(), cost_usd: 0.5, turn_count: 10, tool_call_count: 5, duration_minutes: 15.0, model: "opus".into(), }; let json = serde_json::to_string(&r).unwrap(); assert!(json.contains("durationMinutes")); } }