Skip to main content

opensession_local_db/
lib.rs

1pub mod git;
2
3use anyhow::{Context, Result};
4use opensession_core::trace::Session;
5use rusqlite::{params, Connection, OptionalExtension};
6use serde_json::Value;
7use std::collections::HashSet;
8use std::fs;
9use std::path::PathBuf;
10use std::sync::Mutex;
11
12use git::GitContext;
13
14type Migration = (&'static str, &'static str);
15
16// Keep local index/cache schema aligned with remote/session schema migrations.
17const REMOTE_MIGRATIONS: &[Migration] = &[
18    ("0001_schema", include_str!("../migrations/0001_schema.sql")),
19    (
20        "0003_max_active_agents",
21        include_str!("../migrations/0003_max_active_agents.sql"),
22    ),
23    (
24        "0004_oauth_states_provider",
25        include_str!("../migrations/0004_oauth_states_provider.sql"),
26    ),
27    (
28        "0005_sessions_body_url_backfill",
29        include_str!("../migrations/0005_sessions_body_url_backfill.sql"),
30    ),
31    (
32        "0006_sessions_remove_fk_constraints",
33        include_str!("../migrations/0006_sessions_remove_fk_constraints.sql"),
34    ),
35    (
36        "0007_sessions_list_perf_indexes",
37        include_str!("../migrations/0007_sessions_list_perf_indexes.sql"),
38    ),
39    (
40        "0009_session_score_plugin",
41        include_str!("../migrations/0009_session_score_plugin.sql"),
42    ),
43    (
44        "0010_api_keys_issuance",
45        include_str!("../migrations/0010_api_keys_issuance.sql"),
46    ),
47];
48
49const LOCAL_MIGRATIONS: &[Migration] = &[
50    (
51        "local_0001_schema",
52        include_str!("../migrations/local_0001_schema.sql"),
53    ),
54    (
55        "local_0002_drop_unused_local_sessions",
56        include_str!("../migrations/local_0002_drop_unused_local_sessions.sql"),
57    ),
58];
59
60/// A local session row stored in the local SQLite index/cache database.
61#[derive(Debug, Clone)]
62pub struct LocalSessionRow {
63    pub id: String,
64    pub source_path: Option<String>,
65    pub sync_status: String,
66    pub last_synced_at: Option<String>,
67    pub user_id: Option<String>,
68    pub nickname: Option<String>,
69    pub team_id: Option<String>,
70    pub tool: String,
71    pub agent_provider: Option<String>,
72    pub agent_model: Option<String>,
73    pub title: Option<String>,
74    pub description: Option<String>,
75    pub tags: Option<String>,
76    pub created_at: String,
77    pub uploaded_at: Option<String>,
78    pub message_count: i64,
79    pub user_message_count: i64,
80    pub task_count: i64,
81    pub event_count: i64,
82    pub duration_seconds: i64,
83    pub total_input_tokens: i64,
84    pub total_output_tokens: i64,
85    pub git_remote: Option<String>,
86    pub git_branch: Option<String>,
87    pub git_commit: Option<String>,
88    pub git_repo_name: Option<String>,
89    pub pr_number: Option<i64>,
90    pub pr_url: Option<String>,
91    pub working_directory: Option<String>,
92    pub files_modified: Option<String>,
93    pub files_read: Option<String>,
94    pub has_errors: bool,
95    pub max_active_agents: i64,
96}
97
98/// A link between a git commit and an AI session.
99#[derive(Debug, Clone)]
100pub struct CommitLink {
101    pub commit_hash: String,
102    pub session_id: String,
103    pub repo_path: Option<String>,
104    pub branch: Option<String>,
105    pub created_at: String,
106}
107
108/// Return true when a cached row corresponds to an OpenCode child session.
109pub fn is_opencode_child_session(row: &LocalSessionRow) -> bool {
110    if row.tool != "opencode" {
111        return false;
112    }
113
114    // Strong heuristic: some orchestration artifacts are generated as sessions
115    // that do not carry a user-visible request surface and only contain system
116    // or agent-only events.
117    if row.user_message_count <= 0
118        && row.message_count <= 4
119        && row.task_count <= 4
120        && row.event_count > 0
121        && row.event_count <= 16
122    {
123        return true;
124    }
125
126    if is_opencode_subagent_source(row.source_path.as_deref()) {
127        return true;
128    }
129
130    let source_path = match row.source_path.as_deref() {
131        Some(path) if !path.trim().is_empty() => path,
132        _ => return false,
133    };
134
135    parse_opencode_parent_session_id(source_path)
136        .is_some_and(|parent_id| !parent_id.trim().is_empty())
137}
138
139/// Parse `parentID` / `parentId` from an OpenCode session JSON file.
140pub fn parse_opencode_parent_session_id(source_path: &str) -> Option<String> {
141    let text = fs::read_to_string(source_path).ok()?;
142    let json: Value = serde_json::from_str(&text).ok()?;
143    lookup_parent_session_id(&json)
144}
145
146fn lookup_parent_session_id(value: &Value) -> Option<String> {
147    match value {
148        Value::Object(obj) => {
149            for (key, value) in obj {
150                if is_parent_id_key(key) {
151                    if let Some(parent_id) = value.as_str() {
152                        let parent_id = parent_id.trim();
153                        if !parent_id.is_empty() {
154                            return Some(parent_id.to_string());
155                        }
156                    }
157                }
158                if let Some(parent_id) = lookup_parent_session_id(value) {
159                    return Some(parent_id);
160                }
161            }
162            None
163        }
164        Value::Array(items) => items.iter().find_map(lookup_parent_session_id),
165        _ => None,
166    }
167}
168
169fn is_parent_id_key(key: &str) -> bool {
170    let flat = key
171        .chars()
172        .filter(|c| c.is_ascii_alphanumeric())
173        .map(|c| c.to_ascii_lowercase())
174        .collect::<String>();
175
176    flat == "parentid"
177        || flat == "parentuuid"
178        || flat == "parentsessionid"
179        || flat == "parentsessionuuid"
180        || flat.ends_with("parentsessionid")
181        || (flat.contains("parent") && flat.ends_with("id"))
182        || (flat.contains("parent") && flat.ends_with("uuid"))
183}
184
185/// Remove OpenCode child sessions so only parent sessions remain visible.
186pub fn hide_opencode_child_sessions(mut rows: Vec<LocalSessionRow>) -> Vec<LocalSessionRow> {
187    rows.retain(|row| !is_opencode_child_session(row) && !is_claude_subagent_session(row));
188    rows
189}
190
191fn is_opencode_subagent_source(source_path: Option<&str>) -> bool {
192    is_subagent_source(source_path)
193}
194
195fn is_claude_subagent_session(row: &LocalSessionRow) -> bool {
196    if row.tool != "claude-code" {
197        return false;
198    }
199
200    is_subagent_source(row.source_path.as_deref())
201}
202
203fn is_subagent_source(source_path: Option<&str>) -> bool {
204    let Some(source_path) = source_path.map(|path| path.to_ascii_lowercase()) else {
205        return false;
206    };
207
208    if source_path.contains("/subagents/") || source_path.contains("\\subagents\\") {
209        return true;
210    }
211
212    let filename = match std::path::Path::new(&source_path).file_name() {
213        Some(name) => name.to_string_lossy(),
214        None => return false,
215    };
216
217    filename.starts_with("agent-")
218        || filename.starts_with("agent_")
219        || filename.starts_with("subagent-")
220        || filename.starts_with("subagent_")
221}
222
223/// Filter for listing sessions from the local DB.
224#[derive(Debug, Clone)]
225pub struct LocalSessionFilter {
226    pub team_id: Option<String>,
227    pub sync_status: Option<String>,
228    pub git_repo_name: Option<String>,
229    pub search: Option<String>,
230    pub tool: Option<String>,
231    pub sort: LocalSortOrder,
232    pub time_range: LocalTimeRange,
233    pub limit: Option<u32>,
234    pub offset: Option<u32>,
235}
236
237impl Default for LocalSessionFilter {
238    fn default() -> Self {
239        Self {
240            team_id: None,
241            sync_status: None,
242            git_repo_name: None,
243            search: None,
244            tool: None,
245            sort: LocalSortOrder::Recent,
246            time_range: LocalTimeRange::All,
247            limit: None,
248            offset: None,
249        }
250    }
251}
252
253/// Sort order for local session listing.
254#[derive(Debug, Clone, Default, PartialEq, Eq)]
255pub enum LocalSortOrder {
256    #[default]
257    Recent,
258    Popular,
259    Longest,
260}
261
262/// Time range filter for local session listing.
263#[derive(Debug, Clone, Default, PartialEq, Eq)]
264pub enum LocalTimeRange {
265    Hours24,
266    Days7,
267    Days30,
268    #[default]
269    All,
270}
271
272/// Minimal remote session payload needed for local index/cache upsert.
273#[derive(Debug, Clone)]
274pub struct RemoteSessionSummary {
275    pub id: String,
276    pub user_id: Option<String>,
277    pub nickname: Option<String>,
278    pub team_id: String,
279    pub tool: String,
280    pub agent_provider: Option<String>,
281    pub agent_model: Option<String>,
282    pub title: Option<String>,
283    pub description: Option<String>,
284    pub tags: Option<String>,
285    pub created_at: String,
286    pub uploaded_at: String,
287    pub message_count: i64,
288    pub task_count: i64,
289    pub event_count: i64,
290    pub duration_seconds: i64,
291    pub total_input_tokens: i64,
292    pub total_output_tokens: i64,
293    pub git_remote: Option<String>,
294    pub git_branch: Option<String>,
295    pub git_commit: Option<String>,
296    pub git_repo_name: Option<String>,
297    pub pr_number: Option<i64>,
298    pub pr_url: Option<String>,
299    pub working_directory: Option<String>,
300    pub files_modified: Option<String>,
301    pub files_read: Option<String>,
302    pub has_errors: bool,
303    pub max_active_agents: i64,
304}
305
306/// Extended filter for the `log` command.
307#[derive(Debug, Default)]
308pub struct LogFilter {
309    /// Filter by tool name (exact match).
310    pub tool: Option<String>,
311    /// Filter by model (glob-like, uses LIKE).
312    pub model: Option<String>,
313    /// Filter sessions created after this ISO8601 timestamp.
314    pub since: Option<String>,
315    /// Filter sessions created before this ISO8601 timestamp.
316    pub before: Option<String>,
317    /// Filter sessions that touched this file path (searches files_modified JSON).
318    pub touches: Option<String>,
319    /// Free-text search in title, description, tags.
320    pub grep: Option<String>,
321    /// Only sessions with errors.
322    pub has_errors: Option<bool>,
323    /// Filter by working directory (prefix match).
324    pub working_directory: Option<String>,
325    /// Filter by git repo name.
326    pub git_repo_name: Option<String>,
327    /// Filter sessions linked to this git commit hash.
328    pub commit: Option<String>,
329    /// Maximum number of results.
330    pub limit: Option<u32>,
331    /// Offset for pagination.
332    pub offset: Option<u32>,
333}
334
335/// Base FROM clause for session list queries.
336const FROM_CLAUSE: &str = "\
337FROM sessions s \
338LEFT JOIN session_sync ss ON ss.session_id = s.id \
339LEFT JOIN users u ON u.id = s.user_id";
340
341/// Local SQLite index/cache shared by TUI and Daemon.
342/// This is not the source of truth for canonical session bodies.
343/// Thread-safe: wraps the connection in a Mutex so it can be shared via `Arc<LocalDb>`.
344pub struct LocalDb {
345    conn: Mutex<Connection>,
346}
347
348impl LocalDb {
349    /// Open (or create) the local database at the default path.
350    /// `~/.local/share/opensession/local.db`
351    pub fn open() -> Result<Self> {
352        let path = default_db_path()?;
353        Self::open_path(&path)
354    }
355
356    /// Open (or create) the local database at a specific path.
357    pub fn open_path(path: &PathBuf) -> Result<Self> {
358        if let Some(parent) = path.parent() {
359            std::fs::create_dir_all(parent)
360                .with_context(|| format!("create dir for {}", path.display()))?;
361        }
362        match open_connection_with_latest_schema(path) {
363            Ok(conn) => Ok(Self {
364                conn: Mutex::new(conn),
365            }),
366            Err(err) => {
367                if !is_schema_compat_error(&err) {
368                    return Err(err);
369                }
370
371                // Local DB is a cache. If schema migration cannot safely reconcile
372                // an incompatible/corrupted file, rotate it out and recreate latest schema.
373                rotate_legacy_db(path)?;
374
375                let conn = open_connection_with_latest_schema(path)
376                    .with_context(|| format!("recreate db {}", path.display()))?;
377                Ok(Self {
378                    conn: Mutex::new(conn),
379                })
380            }
381        }
382    }
383
384    fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
385        self.conn.lock().expect("local db mutex poisoned")
386    }
387
388    // ── Upsert local session (parsed from file) ────────────────────────
389
390    pub fn upsert_local_session(
391        &self,
392        session: &Session,
393        source_path: &str,
394        git: &GitContext,
395    ) -> Result<()> {
396        let title = session.context.title.as_deref();
397        let description = session.context.description.as_deref();
398        let tags = if session.context.tags.is_empty() {
399            None
400        } else {
401            Some(session.context.tags.join(","))
402        };
403        let created_at = session.context.created_at.to_rfc3339();
404        let cwd = session
405            .context
406            .attributes
407            .get("cwd")
408            .or_else(|| session.context.attributes.get("working_directory"))
409            .and_then(|v| v.as_str().map(String::from));
410
411        // Extract files_modified, files_read, and has_errors from events
412        let (files_modified, files_read, has_errors) =
413            opensession_core::extract::extract_file_metadata(session);
414        let max_active_agents = opensession_core::agent_metrics::max_active_agents(session) as i64;
415
416        let conn = self.conn();
417        // NOTE: `body_storage_key` is kept only for migration/schema parity.
418        // Runtime lookup uses canonical body URLs and local body cache tables.
419        conn.execute(
420            "INSERT INTO sessions \
421             (id, team_id, tool, agent_provider, agent_model, \
422              title, description, tags, created_at, \
423              message_count, user_message_count, task_count, event_count, duration_seconds, \
424              total_input_tokens, total_output_tokens, body_storage_key, \
425              git_remote, git_branch, git_commit, git_repo_name, working_directory, \
426              files_modified, files_read, has_errors, max_active_agents) \
427             VALUES (?1,'personal',?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,'',?16,?17,?18,?19,?20,?21,?22,?23,?24) \
428             ON CONFLICT(id) DO UPDATE SET \
429              tool=excluded.tool, agent_provider=excluded.agent_provider, \
430              agent_model=excluded.agent_model, \
431              title=excluded.title, description=excluded.description, \
432              tags=excluded.tags, \
433              message_count=excluded.message_count, user_message_count=excluded.user_message_count, \
434              task_count=excluded.task_count, \
435              event_count=excluded.event_count, duration_seconds=excluded.duration_seconds, \
436              total_input_tokens=excluded.total_input_tokens, \
437              total_output_tokens=excluded.total_output_tokens, \
438              git_remote=excluded.git_remote, git_branch=excluded.git_branch, \
439              git_commit=excluded.git_commit, git_repo_name=excluded.git_repo_name, \
440              working_directory=excluded.working_directory, \
441              files_modified=excluded.files_modified, files_read=excluded.files_read, \
442              has_errors=excluded.has_errors, \
443              max_active_agents=excluded.max_active_agents",
444            params![
445                &session.session_id,
446                &session.agent.tool,
447                &session.agent.provider,
448                &session.agent.model,
449                title,
450                description,
451                &tags,
452                &created_at,
453                session.stats.message_count as i64,
454                session.stats.user_message_count as i64,
455                session.stats.task_count as i64,
456                session.stats.event_count as i64,
457                session.stats.duration_seconds as i64,
458                session.stats.total_input_tokens as i64,
459                session.stats.total_output_tokens as i64,
460                &git.remote,
461                &git.branch,
462                &git.commit,
463                &git.repo_name,
464                &cwd,
465                &files_modified,
466                &files_read,
467                has_errors,
468                max_active_agents,
469            ],
470        )?;
471
472        conn.execute(
473            "INSERT INTO session_sync (session_id, source_path, sync_status) \
474             VALUES (?1, ?2, 'local_only') \
475             ON CONFLICT(session_id) DO UPDATE SET source_path=excluded.source_path",
476            params![&session.session_id, source_path],
477        )?;
478        Ok(())
479    }
480
481    // ── Upsert remote session (from server sync pull) ──────────────────
482
483    pub fn upsert_remote_session(&self, summary: &RemoteSessionSummary) -> Result<()> {
484        let conn = self.conn();
485        // NOTE: `body_storage_key` is kept only for migration/schema parity.
486        // Runtime lookup uses canonical body URLs and local body cache tables.
487        conn.execute(
488            "INSERT INTO sessions \
489             (id, user_id, team_id, tool, agent_provider, agent_model, \
490              title, description, tags, created_at, uploaded_at, \
491              message_count, task_count, event_count, duration_seconds, \
492              total_input_tokens, total_output_tokens, body_storage_key, \
493              git_remote, git_branch, git_commit, git_repo_name, \
494              pr_number, pr_url, working_directory, \
495              files_modified, files_read, has_errors, max_active_agents) \
496             VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,'',?18,?19,?20,?21,?22,?23,?24,?25,?26,?27,?28) \
497             ON CONFLICT(id) DO UPDATE SET \
498              title=excluded.title, description=excluded.description, \
499              tags=excluded.tags, uploaded_at=excluded.uploaded_at, \
500              message_count=excluded.message_count, task_count=excluded.task_count, \
501              event_count=excluded.event_count, duration_seconds=excluded.duration_seconds, \
502              total_input_tokens=excluded.total_input_tokens, \
503              total_output_tokens=excluded.total_output_tokens, \
504              git_remote=excluded.git_remote, git_branch=excluded.git_branch, \
505              git_commit=excluded.git_commit, git_repo_name=excluded.git_repo_name, \
506              pr_number=excluded.pr_number, pr_url=excluded.pr_url, \
507              working_directory=excluded.working_directory, \
508              files_modified=excluded.files_modified, files_read=excluded.files_read, \
509              has_errors=excluded.has_errors, \
510              max_active_agents=excluded.max_active_agents",
511            params![
512                &summary.id,
513                &summary.user_id,
514                &summary.team_id,
515                &summary.tool,
516                &summary.agent_provider,
517                &summary.agent_model,
518                &summary.title,
519                &summary.description,
520                &summary.tags,
521                &summary.created_at,
522                &summary.uploaded_at,
523                summary.message_count,
524                summary.task_count,
525                summary.event_count,
526                summary.duration_seconds,
527                summary.total_input_tokens,
528                summary.total_output_tokens,
529                &summary.git_remote,
530                &summary.git_branch,
531                &summary.git_commit,
532                &summary.git_repo_name,
533                summary.pr_number,
534                &summary.pr_url,
535                &summary.working_directory,
536                &summary.files_modified,
537                &summary.files_read,
538                summary.has_errors,
539                summary.max_active_agents,
540            ],
541        )?;
542
543        conn.execute(
544            "INSERT INTO session_sync (session_id, sync_status) \
545             VALUES (?1, 'remote_only') \
546             ON CONFLICT(session_id) DO UPDATE SET \
547              sync_status = CASE WHEN session_sync.sync_status = 'local_only' THEN 'synced' ELSE session_sync.sync_status END",
548            params![&summary.id],
549        )?;
550        Ok(())
551    }
552
553    // ── List sessions ──────────────────────────────────────────────────
554
555    fn build_local_session_where_clause(
556        filter: &LocalSessionFilter,
557    ) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
558        let mut where_clauses = vec![
559            "1=1".to_string(),
560            "NOT (s.tool = 'claude-code' AND (LOWER(COALESCE(ss.source_path, '')) LIKE '%/subagents/%' OR LOWER(COALESCE(ss.source_path, '')) LIKE '%\\\\subagents\\\\%'))".to_string(),
561            "NOT (s.tool = 'opencode' AND (LOWER(COALESCE(ss.source_path, '')) LIKE '%/subagents/%' OR LOWER(COALESCE(ss.source_path, '')) LIKE '%\\\\subagents\\\\%'))".to_string(),
562            "NOT (s.tool = 'opencode' AND COALESCE(s.user_message_count, 0) <= 0 AND COALESCE(s.message_count, 0) <= 4 AND COALESCE(s.task_count, 0) <= 4 AND COALESCE(s.event_count, 0) > 0 AND COALESCE(s.event_count, 0) <= 16)".to_string(),
563        ];
564        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
565        let mut idx = 1u32;
566
567        if let Some(ref team_id) = filter.team_id {
568            where_clauses.push(format!("s.team_id = ?{idx}"));
569            param_values.push(Box::new(team_id.clone()));
570            idx += 1;
571        }
572
573        if let Some(ref sync_status) = filter.sync_status {
574            where_clauses.push(format!("COALESCE(ss.sync_status, 'unknown') = ?{idx}"));
575            param_values.push(Box::new(sync_status.clone()));
576            idx += 1;
577        }
578
579        if let Some(ref repo) = filter.git_repo_name {
580            where_clauses.push(format!("s.git_repo_name = ?{idx}"));
581            param_values.push(Box::new(repo.clone()));
582            idx += 1;
583        }
584
585        if let Some(ref tool) = filter.tool {
586            where_clauses.push(format!("s.tool = ?{idx}"));
587            param_values.push(Box::new(tool.clone()));
588            idx += 1;
589        }
590
591        if let Some(ref search) = filter.search {
592            let like = format!("%{search}%");
593            where_clauses.push(format!(
594                "(s.title LIKE ?{i1} OR s.description LIKE ?{i2} OR s.tags LIKE ?{i3})",
595                i1 = idx,
596                i2 = idx + 1,
597                i3 = idx + 2,
598            ));
599            param_values.push(Box::new(like.clone()));
600            param_values.push(Box::new(like.clone()));
601            param_values.push(Box::new(like));
602            idx += 3;
603        }
604
605        let interval = match filter.time_range {
606            LocalTimeRange::Hours24 => Some("-1 day"),
607            LocalTimeRange::Days7 => Some("-7 days"),
608            LocalTimeRange::Days30 => Some("-30 days"),
609            LocalTimeRange::All => None,
610        };
611        if let Some(interval) = interval {
612            where_clauses.push(format!("datetime(s.created_at) >= datetime('now', ?{idx})"));
613            param_values.push(Box::new(interval.to_string()));
614        }
615
616        (where_clauses.join(" AND "), param_values)
617    }
618
619    pub fn list_sessions(&self, filter: &LocalSessionFilter) -> Result<Vec<LocalSessionRow>> {
620        let (where_str, mut param_values) = Self::build_local_session_where_clause(filter);
621        let order_clause = match filter.sort {
622            LocalSortOrder::Popular => "s.message_count DESC, s.created_at DESC",
623            LocalSortOrder::Longest => "s.duration_seconds DESC, s.created_at DESC",
624            LocalSortOrder::Recent => "s.created_at DESC",
625        };
626
627        let mut sql = format!(
628            "SELECT {LOCAL_SESSION_COLUMNS} \
629             {FROM_CLAUSE} WHERE {where_str} \
630             ORDER BY {order_clause}"
631        );
632
633        if let Some(limit) = filter.limit {
634            sql.push_str(" LIMIT ?");
635            param_values.push(Box::new(limit));
636            if let Some(offset) = filter.offset {
637                sql.push_str(" OFFSET ?");
638                param_values.push(Box::new(offset));
639            }
640        }
641
642        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
643            param_values.iter().map(|p| p.as_ref()).collect();
644        let conn = self.conn();
645        let mut stmt = conn.prepare(&sql)?;
646        let rows = stmt.query_map(param_refs.as_slice(), row_to_local_session)?;
647
648        let mut result = Vec::new();
649        for row in rows {
650            result.push(row?);
651        }
652
653        Ok(hide_opencode_child_sessions(result))
654    }
655
656    /// Count sessions for a given list filter (before UI-level page slicing).
657    pub fn count_sessions_filtered(&self, filter: &LocalSessionFilter) -> Result<i64> {
658        let mut count_filter = filter.clone();
659        count_filter.limit = None;
660        count_filter.offset = None;
661        let (where_str, param_values) = Self::build_local_session_where_clause(&count_filter);
662        let sql = format!("SELECT COUNT(*) {FROM_CLAUSE} WHERE {where_str}");
663        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
664            param_values.iter().map(|p| p.as_ref()).collect();
665        let conn = self.conn();
666        let count = conn.query_row(&sql, param_refs.as_slice(), |row| row.get(0))?;
667        Ok(count)
668    }
669
670    /// List distinct tool names for the current list filter (ignores active tool filter).
671    pub fn list_session_tools(&self, filter: &LocalSessionFilter) -> Result<Vec<String>> {
672        let mut tool_filter = filter.clone();
673        tool_filter.tool = None;
674        tool_filter.limit = None;
675        tool_filter.offset = None;
676        let (where_str, param_values) = Self::build_local_session_where_clause(&tool_filter);
677        let sql = format!(
678            "SELECT DISTINCT s.tool \
679             {FROM_CLAUSE} WHERE {where_str} \
680             ORDER BY s.tool ASC"
681        );
682        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
683            param_values.iter().map(|p| p.as_ref()).collect();
684        let conn = self.conn();
685        let mut stmt = conn.prepare(&sql)?;
686        let rows = stmt.query_map(param_refs.as_slice(), |row| row.get::<_, String>(0))?;
687
688        let mut tools = Vec::new();
689        for row in rows {
690            let tool = row?;
691            if !tool.trim().is_empty() {
692                tools.push(tool);
693            }
694        }
695        Ok(tools)
696    }
697
698    // ── Log query ─────────────────────────────────────────────────────
699
700    /// Query sessions with extended filters for the `log` command.
701    pub fn list_sessions_log(&self, filter: &LogFilter) -> Result<Vec<LocalSessionRow>> {
702        let mut where_clauses = vec!["1=1".to_string()];
703        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
704        let mut idx = 1u32;
705
706        if let Some(ref tool) = filter.tool {
707            where_clauses.push(format!("s.tool = ?{idx}"));
708            param_values.push(Box::new(tool.clone()));
709            idx += 1;
710        }
711
712        if let Some(ref model) = filter.model {
713            let like = model.replace('*', "%");
714            where_clauses.push(format!("s.agent_model LIKE ?{idx}"));
715            param_values.push(Box::new(like));
716            idx += 1;
717        }
718
719        if let Some(ref since) = filter.since {
720            where_clauses.push(format!("s.created_at >= ?{idx}"));
721            param_values.push(Box::new(since.clone()));
722            idx += 1;
723        }
724
725        if let Some(ref before) = filter.before {
726            where_clauses.push(format!("s.created_at < ?{idx}"));
727            param_values.push(Box::new(before.clone()));
728            idx += 1;
729        }
730
731        if let Some(ref touches) = filter.touches {
732            let like = format!("%\"{touches}\"%");
733            where_clauses.push(format!("s.files_modified LIKE ?{idx}"));
734            param_values.push(Box::new(like));
735            idx += 1;
736        }
737
738        if let Some(ref grep) = filter.grep {
739            let like = format!("%{grep}%");
740            where_clauses.push(format!(
741                "(s.title LIKE ?{i1} OR s.description LIKE ?{i2} OR s.tags LIKE ?{i3})",
742                i1 = idx,
743                i2 = idx + 1,
744                i3 = idx + 2,
745            ));
746            param_values.push(Box::new(like.clone()));
747            param_values.push(Box::new(like.clone()));
748            param_values.push(Box::new(like));
749            idx += 3;
750        }
751
752        if let Some(true) = filter.has_errors {
753            where_clauses.push("s.has_errors = 1".to_string());
754        }
755
756        if let Some(ref wd) = filter.working_directory {
757            where_clauses.push(format!("s.working_directory LIKE ?{idx}"));
758            param_values.push(Box::new(format!("{wd}%")));
759            idx += 1;
760        }
761
762        if let Some(ref repo) = filter.git_repo_name {
763            where_clauses.push(format!("s.git_repo_name = ?{idx}"));
764            param_values.push(Box::new(repo.clone()));
765            idx += 1;
766        }
767
768        // Optional JOIN for commit hash filter
769        let mut extra_join = String::new();
770        if let Some(ref commit) = filter.commit {
771            extra_join =
772                " INNER JOIN commit_session_links csl ON csl.session_id = s.id".to_string();
773            where_clauses.push(format!("csl.commit_hash = ?{idx}"));
774            param_values.push(Box::new(commit.clone()));
775            idx += 1;
776        }
777
778        let _ = idx; // suppress unused warning
779
780        let where_str = where_clauses.join(" AND ");
781        let mut sql = format!(
782            "SELECT {LOCAL_SESSION_COLUMNS} \
783             {FROM_CLAUSE}{extra_join} WHERE {where_str} \
784             ORDER BY s.created_at DESC"
785        );
786
787        if let Some(limit) = filter.limit {
788            sql.push_str(" LIMIT ?");
789            param_values.push(Box::new(limit));
790            if let Some(offset) = filter.offset {
791                sql.push_str(" OFFSET ?");
792                param_values.push(Box::new(offset));
793            }
794        }
795
796        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
797            param_values.iter().map(|p| p.as_ref()).collect();
798        let conn = self.conn();
799        let mut stmt = conn.prepare(&sql)?;
800        let rows = stmt.query_map(param_refs.as_slice(), row_to_local_session)?;
801
802        let mut result = Vec::new();
803        for row in rows {
804            result.push(row?);
805        }
806        Ok(hide_opencode_child_sessions(result))
807    }
808
809    /// Get the latest N sessions for a specific tool, ordered by created_at DESC.
810    pub fn get_sessions_by_tool_latest(
811        &self,
812        tool: &str,
813        count: u32,
814    ) -> Result<Vec<LocalSessionRow>> {
815        let sql = format!(
816            "SELECT {LOCAL_SESSION_COLUMNS} \
817             {FROM_CLAUSE} WHERE s.tool = ?1 \
818             ORDER BY s.created_at DESC"
819        );
820        let conn = self.conn();
821        let mut stmt = conn.prepare(&sql)?;
822        let rows = stmt.query_map(params![tool], row_to_local_session)?;
823        let mut result = Vec::new();
824        for row in rows {
825            result.push(row?);
826        }
827
828        let mut filtered = hide_opencode_child_sessions(result);
829        filtered.truncate(count as usize);
830        Ok(filtered)
831    }
832
833    /// Get the latest N sessions across all tools, ordered by created_at DESC.
834    pub fn get_sessions_latest(&self, count: u32) -> Result<Vec<LocalSessionRow>> {
835        let sql = format!(
836            "SELECT {LOCAL_SESSION_COLUMNS} \
837             {FROM_CLAUSE} \
838             ORDER BY s.created_at DESC"
839        );
840        let conn = self.conn();
841        let mut stmt = conn.prepare(&sql)?;
842        let rows = stmt.query_map([], row_to_local_session)?;
843        let mut result = Vec::new();
844        for row in rows {
845            result.push(row?);
846        }
847
848        let mut filtered = hide_opencode_child_sessions(result);
849        filtered.truncate(count as usize);
850        Ok(filtered)
851    }
852
853    /// Get the Nth most recent session for a specific tool (0 = HEAD, 1 = HEAD~1, etc.).
854    pub fn get_session_by_tool_offset(
855        &self,
856        tool: &str,
857        offset: u32,
858    ) -> Result<Option<LocalSessionRow>> {
859        let sql = format!(
860            "SELECT {LOCAL_SESSION_COLUMNS} \
861             {FROM_CLAUSE} WHERE s.tool = ?1 \
862             ORDER BY s.created_at DESC"
863        );
864        let conn = self.conn();
865        let mut stmt = conn.prepare(&sql)?;
866        let rows = stmt.query_map(params![tool], row_to_local_session)?;
867        let filtered = hide_opencode_child_sessions(rows.collect::<Result<Vec<_>, _>>()?);
868        Ok(filtered.into_iter().nth(offset as usize))
869    }
870
871    /// Get the Nth most recent session across all tools (0 = HEAD, 1 = HEAD~1, etc.).
872    pub fn get_session_by_offset(&self, offset: u32) -> Result<Option<LocalSessionRow>> {
873        let sql = format!(
874            "SELECT {LOCAL_SESSION_COLUMNS} \
875             {FROM_CLAUSE} \
876             ORDER BY s.created_at DESC"
877        );
878        let conn = self.conn();
879        let mut stmt = conn.prepare(&sql)?;
880        let rows = stmt.query_map([], row_to_local_session)?;
881        let filtered = hide_opencode_child_sessions(rows.collect::<Result<Vec<_>, _>>()?);
882        Ok(filtered.into_iter().nth(offset as usize))
883    }
884
885    /// Fetch the source path used when the session was last parsed/loaded.
886    pub fn get_session_source_path(&self, session_id: &str) -> Result<Option<String>> {
887        let conn = self.conn();
888        let result = conn
889            .query_row(
890                "SELECT source_path FROM session_sync WHERE session_id = ?1",
891                params![session_id],
892                |row| row.get(0),
893            )
894            .optional()?;
895
896        Ok(result)
897    }
898
899    /// Count total sessions in the local DB.
900    pub fn session_count(&self) -> Result<i64> {
901        let count = self
902            .conn()
903            .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
904        Ok(count)
905    }
906
907    // ── Delete session ─────────────────────────────────────────────────
908
909    pub fn delete_session(&self, session_id: &str) -> Result<()> {
910        let conn = self.conn();
911        conn.execute(
912            "DELETE FROM body_cache WHERE session_id = ?1",
913            params![session_id],
914        )?;
915        conn.execute(
916            "DELETE FROM session_sync WHERE session_id = ?1",
917            params![session_id],
918        )?;
919        conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
920        Ok(())
921    }
922
923    // ── Sync cursor ────────────────────────────────────────────────────
924
925    pub fn get_sync_cursor(&self, team_id: &str) -> Result<Option<String>> {
926        let cursor = self
927            .conn()
928            .query_row(
929                "SELECT cursor FROM sync_cursors WHERE team_id = ?1",
930                params![team_id],
931                |row| row.get(0),
932            )
933            .optional()?;
934        Ok(cursor)
935    }
936
937    pub fn set_sync_cursor(&self, team_id: &str, cursor: &str) -> Result<()> {
938        self.conn().execute(
939            "INSERT INTO sync_cursors (team_id, cursor, updated_at) \
940             VALUES (?1, ?2, datetime('now')) \
941             ON CONFLICT(team_id) DO UPDATE SET cursor=excluded.cursor, updated_at=datetime('now')",
942            params![team_id, cursor],
943        )?;
944        Ok(())
945    }
946
947    // ── Upload tracking ────────────────────────────────────────────────
948
949    /// Get sessions that are local_only and need to be uploaded.
950    pub fn pending_uploads(&self, team_id: &str) -> Result<Vec<LocalSessionRow>> {
951        let sql = format!(
952            "SELECT {LOCAL_SESSION_COLUMNS} \
953             FROM sessions s \
954             INNER JOIN session_sync ss ON ss.session_id = s.id \
955             LEFT JOIN users u ON u.id = s.user_id \
956             WHERE ss.sync_status = 'local_only' AND s.team_id = ?1 \
957             ORDER BY s.created_at ASC"
958        );
959        let conn = self.conn();
960        let mut stmt = conn.prepare(&sql)?;
961        let rows = stmt.query_map(params![team_id], row_to_local_session)?;
962        let mut result = Vec::new();
963        for row in rows {
964            result.push(row?);
965        }
966        Ok(result)
967    }
968
969    pub fn mark_synced(&self, session_id: &str) -> Result<()> {
970        self.conn().execute(
971            "UPDATE session_sync SET sync_status = 'synced', last_synced_at = datetime('now') \
972             WHERE session_id = ?1",
973            params![session_id],
974        )?;
975        Ok(())
976    }
977
978    /// Check if a session was already uploaded (synced or remote_only) since the given modification time.
979    pub fn was_uploaded_after(
980        &self,
981        source_path: &str,
982        modified: &chrono::DateTime<chrono::Utc>,
983    ) -> Result<bool> {
984        let result: Option<String> = self
985            .conn()
986            .query_row(
987                "SELECT last_synced_at FROM session_sync \
988                 WHERE source_path = ?1 AND sync_status = 'synced' AND last_synced_at IS NOT NULL",
989                params![source_path],
990                |row| row.get(0),
991            )
992            .optional()?;
993
994        if let Some(synced_at) = result {
995            if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&synced_at) {
996                return Ok(dt >= *modified);
997            }
998        }
999        Ok(false)
1000    }
1001
1002    // ── Body cache (local read acceleration) ───────────────────────────
1003
1004    pub fn cache_body(&self, session_id: &str, body: &[u8]) -> Result<()> {
1005        self.conn().execute(
1006            "INSERT INTO body_cache (session_id, body, cached_at) \
1007             VALUES (?1, ?2, datetime('now')) \
1008             ON CONFLICT(session_id) DO UPDATE SET body=excluded.body, cached_at=datetime('now')",
1009            params![session_id, body],
1010        )?;
1011        Ok(())
1012    }
1013
1014    pub fn get_cached_body(&self, session_id: &str) -> Result<Option<Vec<u8>>> {
1015        let body = self
1016            .conn()
1017            .query_row(
1018                "SELECT body FROM body_cache WHERE session_id = ?1",
1019                params![session_id],
1020                |row| row.get(0),
1021            )
1022            .optional()?;
1023        Ok(body)
1024    }
1025
1026    // ── Migration helper ───────────────────────────────────────────────
1027
1028    /// Migrate entries from the old state.json UploadState into the local DB.
1029    /// Marks them as `synced` with no metadata (we only know the file path was uploaded).
1030    pub fn migrate_from_state_json(
1031        &self,
1032        uploaded: &std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
1033    ) -> Result<usize> {
1034        let mut count = 0;
1035        for (path, uploaded_at) in uploaded {
1036            let exists: bool = self
1037                .conn()
1038                .query_row(
1039                    "SELECT COUNT(*) > 0 FROM session_sync WHERE source_path = ?1",
1040                    params![path],
1041                    |row| row.get(0),
1042                )
1043                .unwrap_or(false);
1044
1045            if exists {
1046                self.conn().execute(
1047                    "UPDATE session_sync SET sync_status = 'synced', last_synced_at = ?1 \
1048                     WHERE source_path = ?2 AND sync_status = 'local_only'",
1049                    params![uploaded_at.to_rfc3339(), path],
1050                )?;
1051                count += 1;
1052            }
1053        }
1054        Ok(count)
1055    }
1056
1057    // ── Commit ↔ session linking ────────────────────────────────────
1058
1059    /// Link a git commit to an AI session.
1060    pub fn link_commit_session(
1061        &self,
1062        commit_hash: &str,
1063        session_id: &str,
1064        repo_path: Option<&str>,
1065        branch: Option<&str>,
1066    ) -> Result<()> {
1067        self.conn().execute(
1068            "INSERT INTO commit_session_links (commit_hash, session_id, repo_path, branch) \
1069             VALUES (?1, ?2, ?3, ?4) \
1070             ON CONFLICT(commit_hash, session_id) DO NOTHING",
1071            params![commit_hash, session_id, repo_path, branch],
1072        )?;
1073        Ok(())
1074    }
1075
1076    /// Get all sessions linked to a git commit.
1077    pub fn get_sessions_by_commit(&self, commit_hash: &str) -> Result<Vec<LocalSessionRow>> {
1078        let sql = format!(
1079            "SELECT {LOCAL_SESSION_COLUMNS} \
1080             {FROM_CLAUSE} \
1081             INNER JOIN commit_session_links csl ON csl.session_id = s.id \
1082             WHERE csl.commit_hash = ?1 \
1083             ORDER BY s.created_at DESC"
1084        );
1085        let conn = self.conn();
1086        let mut stmt = conn.prepare(&sql)?;
1087        let rows = stmt.query_map(params![commit_hash], row_to_local_session)?;
1088        let mut result = Vec::new();
1089        for row in rows {
1090            result.push(row?);
1091        }
1092        Ok(result)
1093    }
1094
1095    /// Get all commits linked to a session.
1096    pub fn get_commits_by_session(&self, session_id: &str) -> Result<Vec<CommitLink>> {
1097        let conn = self.conn();
1098        let mut stmt = conn.prepare(
1099            "SELECT commit_hash, session_id, repo_path, branch, created_at \
1100             FROM commit_session_links WHERE session_id = ?1 \
1101             ORDER BY created_at DESC",
1102        )?;
1103        let rows = stmt.query_map(params![session_id], |row| {
1104            Ok(CommitLink {
1105                commit_hash: row.get(0)?,
1106                session_id: row.get(1)?,
1107                repo_path: row.get(2)?,
1108                branch: row.get(3)?,
1109                created_at: row.get(4)?,
1110            })
1111        })?;
1112        let mut result = Vec::new();
1113        for row in rows {
1114            result.push(row?);
1115        }
1116        Ok(result)
1117    }
1118
1119    /// Find the most recently active session for a given repo path.
1120    /// "Active" means the session's working_directory matches the repo path
1121    /// and was created within the last `since_minutes` minutes.
1122    pub fn find_active_session_for_repo(
1123        &self,
1124        repo_path: &str,
1125        since_minutes: u32,
1126    ) -> Result<Option<LocalSessionRow>> {
1127        let sql = format!(
1128            "SELECT {LOCAL_SESSION_COLUMNS} \
1129             {FROM_CLAUSE} \
1130             WHERE s.working_directory LIKE ?1 \
1131             AND s.created_at >= datetime('now', ?2) \
1132             ORDER BY s.created_at DESC LIMIT 1"
1133        );
1134        let since = format!("-{since_minutes} minutes");
1135        let like = format!("{repo_path}%");
1136        let conn = self.conn();
1137        let mut stmt = conn.prepare(&sql)?;
1138        let row = stmt
1139            .query_map(params![like, since], row_to_local_session)?
1140            .next()
1141            .transpose()?;
1142        Ok(row)
1143    }
1144
1145    /// Get all session IDs currently in the local DB.
1146    pub fn existing_session_ids(&self) -> std::collections::HashSet<String> {
1147        let conn = self.conn();
1148        let mut stmt = conn
1149            .prepare("SELECT id FROM sessions")
1150            .unwrap_or_else(|_| panic!("failed to prepare existing_session_ids query"));
1151        let rows = stmt.query_map([], |row| row.get::<_, String>(0));
1152        let mut set = std::collections::HashSet::new();
1153        if let Ok(rows) = rows {
1154            for row in rows.flatten() {
1155                set.insert(row);
1156            }
1157        }
1158        set
1159    }
1160
1161    /// Update only stats fields for an existing session (no git context re-extraction).
1162    pub fn update_session_stats(&self, session: &Session) -> Result<()> {
1163        let title = session.context.title.as_deref();
1164        let description = session.context.description.as_deref();
1165        let (files_modified, files_read, has_errors) =
1166            opensession_core::extract::extract_file_metadata(session);
1167        let max_active_agents = opensession_core::agent_metrics::max_active_agents(session) as i64;
1168
1169        self.conn().execute(
1170            "UPDATE sessions SET \
1171             title=?2, description=?3, \
1172             message_count=?4, user_message_count=?5, task_count=?6, \
1173             event_count=?7, duration_seconds=?8, \
1174             total_input_tokens=?9, total_output_tokens=?10, \
1175             files_modified=?11, files_read=?12, has_errors=?13, \
1176             max_active_agents=?14 \
1177             WHERE id=?1",
1178            params![
1179                &session.session_id,
1180                title,
1181                description,
1182                session.stats.message_count as i64,
1183                session.stats.user_message_count as i64,
1184                session.stats.task_count as i64,
1185                session.stats.event_count as i64,
1186                session.stats.duration_seconds as i64,
1187                session.stats.total_input_tokens as i64,
1188                session.stats.total_output_tokens as i64,
1189                &files_modified,
1190                &files_read,
1191                has_errors,
1192                max_active_agents,
1193            ],
1194        )?;
1195        Ok(())
1196    }
1197
1198    /// Update only sync metadata path for an existing session.
1199    pub fn set_session_sync_path(&self, session_id: &str, source_path: &str) -> Result<()> {
1200        self.conn().execute(
1201            "INSERT INTO session_sync (session_id, source_path) \
1202             VALUES (?1, ?2) \
1203             ON CONFLICT(session_id) DO UPDATE SET source_path = excluded.source_path",
1204            params![session_id, source_path],
1205        )?;
1206        Ok(())
1207    }
1208
1209    /// Get a list of distinct git repo names present in the DB.
1210    pub fn list_repos(&self) -> Result<Vec<String>> {
1211        let conn = self.conn();
1212        let mut stmt = conn.prepare(
1213            "SELECT DISTINCT git_repo_name FROM sessions \
1214             WHERE git_repo_name IS NOT NULL ORDER BY git_repo_name ASC",
1215        )?;
1216        let rows = stmt.query_map([], |row| row.get(0))?;
1217        let mut result = Vec::new();
1218        for row in rows {
1219            result.push(row?);
1220        }
1221        Ok(result)
1222    }
1223
1224    /// Get a list of distinct, non-empty working directories present in the DB.
1225    pub fn list_working_directories(&self) -> Result<Vec<String>> {
1226        let conn = self.conn();
1227        let mut stmt = conn.prepare(
1228            "SELECT DISTINCT working_directory FROM sessions \
1229             WHERE working_directory IS NOT NULL AND TRIM(working_directory) <> '' \
1230             ORDER BY working_directory ASC",
1231        )?;
1232        let rows = stmt.query_map([], |row| row.get(0))?;
1233        let mut result = Vec::new();
1234        for row in rows {
1235            result.push(row?);
1236        }
1237        Ok(result)
1238    }
1239}
1240
1241// ── Schema backfill for existing local DB files ───────────────────────
1242
1243fn open_connection_with_latest_schema(path: &PathBuf) -> Result<Connection> {
1244    let conn = Connection::open(path).with_context(|| format!("open db {}", path.display()))?;
1245    conn.execute_batch("PRAGMA journal_mode=WAL;")?;
1246
1247    // Disable FK constraints for local DB (index/cache, not source of truth)
1248    conn.execute_batch("PRAGMA foreign_keys=OFF;")?;
1249
1250    apply_local_migrations(&conn)?;
1251
1252    // Backfill missing columns for existing local DB files where `sessions`
1253    // existed before newer fields were introduced.
1254    ensure_sessions_columns(&conn)?;
1255    validate_local_schema(&conn)?;
1256
1257    Ok(conn)
1258}
1259
1260fn apply_local_migrations(conn: &Connection) -> Result<()> {
1261    conn.execute_batch(
1262        "CREATE TABLE IF NOT EXISTS _migrations (
1263            id INTEGER PRIMARY KEY,
1264            name TEXT NOT NULL UNIQUE,
1265            applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1266        );",
1267    )
1268    .context("create _migrations table for local db")?;
1269
1270    for (name, sql) in REMOTE_MIGRATIONS.iter().chain(LOCAL_MIGRATIONS.iter()) {
1271        let already_applied: bool = conn
1272            .query_row(
1273                "SELECT COUNT(*) > 0 FROM _migrations WHERE name = ?1",
1274                [name],
1275                |row| row.get(0),
1276            )
1277            .unwrap_or(false);
1278
1279        if already_applied {
1280            continue;
1281        }
1282
1283        if let Err(e) = conn.execute_batch(sql) {
1284            let msg = e.to_string().to_ascii_lowercase();
1285            if !is_local_migration_compat_error(&msg) {
1286                return Err(e).with_context(|| format!("apply local migration {name}"));
1287            }
1288        }
1289
1290        conn.execute(
1291            "INSERT OR IGNORE INTO _migrations (name) VALUES (?1)",
1292            [name],
1293        )
1294        .with_context(|| format!("record local migration {name}"))?;
1295    }
1296
1297    Ok(())
1298}
1299
1300fn is_local_migration_compat_error(msg: &str) -> bool {
1301    msg.contains("duplicate column name")
1302        || msg.contains("no such column")
1303        || msg.contains("already exists")
1304}
1305
1306fn validate_local_schema(conn: &Connection) -> Result<()> {
1307    let sql = format!("SELECT {LOCAL_SESSION_COLUMNS} {FROM_CLAUSE} WHERE 1=0");
1308    conn.prepare(&sql)
1309        .map(|_| ())
1310        .context("validate local session schema")
1311}
1312
1313fn is_schema_compat_error(err: &anyhow::Error) -> bool {
1314    let msg = format!("{err:#}").to_ascii_lowercase();
1315    msg.contains("no such column")
1316        || msg.contains("no such table")
1317        || msg.contains("cannot add a column")
1318        || msg.contains("already exists")
1319        || msg.contains("views may not be indexed")
1320        || msg.contains("malformed database schema")
1321        || msg.contains("duplicate column name")
1322}
1323
1324fn rotate_legacy_db(path: &PathBuf) -> Result<()> {
1325    if !path.exists() {
1326        return Ok(());
1327    }
1328
1329    let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
1330    let backup_name = format!(
1331        "{}.legacy-{}.bak",
1332        path.file_name()
1333            .and_then(|n| n.to_str())
1334            .unwrap_or("local.db"),
1335        ts
1336    );
1337    let backup_path = path.with_file_name(backup_name);
1338    std::fs::rename(path, &backup_path).with_context(|| {
1339        format!(
1340            "rotate local db backup {} -> {}",
1341            path.display(),
1342            backup_path.display()
1343        )
1344    })?;
1345
1346    let wal = PathBuf::from(format!("{}-wal", path.display()));
1347    let shm = PathBuf::from(format!("{}-shm", path.display()));
1348    let _ = std::fs::remove_file(wal);
1349    let _ = std::fs::remove_file(shm);
1350    Ok(())
1351}
1352
1353const REQUIRED_SESSION_COLUMNS: &[(&str, &str)] = &[
1354    ("user_id", "TEXT"),
1355    ("team_id", "TEXT DEFAULT 'personal'"),
1356    ("tool", "TEXT DEFAULT ''"),
1357    ("agent_provider", "TEXT"),
1358    ("agent_model", "TEXT"),
1359    ("title", "TEXT"),
1360    ("description", "TEXT"),
1361    ("tags", "TEXT"),
1362    ("created_at", "TEXT DEFAULT ''"),
1363    ("uploaded_at", "TEXT DEFAULT ''"),
1364    ("message_count", "INTEGER DEFAULT 0"),
1365    ("user_message_count", "INTEGER DEFAULT 0"),
1366    ("task_count", "INTEGER DEFAULT 0"),
1367    ("event_count", "INTEGER DEFAULT 0"),
1368    ("duration_seconds", "INTEGER DEFAULT 0"),
1369    ("total_input_tokens", "INTEGER DEFAULT 0"),
1370    ("total_output_tokens", "INTEGER DEFAULT 0"),
1371    // Migration-only compatibility column from pre git-native body storage.
1372    ("body_storage_key", "TEXT DEFAULT ''"),
1373    ("body_url", "TEXT"),
1374    ("git_remote", "TEXT"),
1375    ("git_branch", "TEXT"),
1376    ("git_commit", "TEXT"),
1377    ("git_repo_name", "TEXT"),
1378    ("pr_number", "INTEGER"),
1379    ("pr_url", "TEXT"),
1380    ("working_directory", "TEXT"),
1381    ("files_modified", "TEXT"),
1382    ("files_read", "TEXT"),
1383    ("has_errors", "BOOLEAN DEFAULT 0"),
1384    ("max_active_agents", "INTEGER DEFAULT 1"),
1385];
1386
1387fn ensure_sessions_columns(conn: &Connection) -> Result<()> {
1388    let mut existing = HashSet::new();
1389    let mut stmt = conn.prepare("PRAGMA table_info(sessions)")?;
1390    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
1391    for row in rows {
1392        existing.insert(row?);
1393    }
1394
1395    for (name, decl) in REQUIRED_SESSION_COLUMNS {
1396        if existing.contains(*name) {
1397            continue;
1398        }
1399        let sql = format!("ALTER TABLE sessions ADD COLUMN {name} {decl};");
1400        conn.execute_batch(&sql)
1401            .with_context(|| format!("add sessions column '{name}'"))?;
1402    }
1403
1404    Ok(())
1405}
1406
1407/// Column list for SELECT queries against sessions + session_sync + users.
1408pub const LOCAL_SESSION_COLUMNS: &str = "\
1409s.id, ss.source_path, COALESCE(ss.sync_status, 'unknown') AS sync_status, ss.last_synced_at, \
1410s.user_id, u.nickname, s.team_id, s.tool, s.agent_provider, s.agent_model, \
1411s.title, s.description, s.tags, s.created_at, s.uploaded_at, \
1412s.message_count, COALESCE(s.user_message_count, 0), s.task_count, s.event_count, s.duration_seconds, \
1413s.total_input_tokens, s.total_output_tokens, \
1414s.git_remote, s.git_branch, s.git_commit, s.git_repo_name, \
1415s.pr_number, s.pr_url, s.working_directory, \
1416s.files_modified, s.files_read, s.has_errors, COALESCE(s.max_active_agents, 1)";
1417
1418fn row_to_local_session(row: &rusqlite::Row) -> rusqlite::Result<LocalSessionRow> {
1419    Ok(LocalSessionRow {
1420        id: row.get(0)?,
1421        source_path: row.get(1)?,
1422        sync_status: row.get(2)?,
1423        last_synced_at: row.get(3)?,
1424        user_id: row.get(4)?,
1425        nickname: row.get(5)?,
1426        team_id: row.get(6)?,
1427        tool: row.get(7)?,
1428        agent_provider: row.get(8)?,
1429        agent_model: row.get(9)?,
1430        title: row.get(10)?,
1431        description: row.get(11)?,
1432        tags: row.get(12)?,
1433        created_at: row.get(13)?,
1434        uploaded_at: row.get(14)?,
1435        message_count: row.get(15)?,
1436        user_message_count: row.get(16)?,
1437        task_count: row.get(17)?,
1438        event_count: row.get(18)?,
1439        duration_seconds: row.get(19)?,
1440        total_input_tokens: row.get(20)?,
1441        total_output_tokens: row.get(21)?,
1442        git_remote: row.get(22)?,
1443        git_branch: row.get(23)?,
1444        git_commit: row.get(24)?,
1445        git_repo_name: row.get(25)?,
1446        pr_number: row.get(26)?,
1447        pr_url: row.get(27)?,
1448        working_directory: row.get(28)?,
1449        files_modified: row.get(29)?,
1450        files_read: row.get(30)?,
1451        has_errors: row.get::<_, i64>(31).unwrap_or(0) != 0,
1452        max_active_agents: row.get(32).unwrap_or(1),
1453    })
1454}
1455
1456fn default_db_path() -> Result<PathBuf> {
1457    let home = std::env::var("HOME")
1458        .or_else(|_| std::env::var("USERPROFILE"))
1459        .context("Could not determine home directory")?;
1460    Ok(PathBuf::from(home)
1461        .join(".local")
1462        .join("share")
1463        .join("opensession")
1464        .join("local.db"))
1465}
1466
1467#[cfg(test)]
1468mod tests {
1469    use super::*;
1470
1471    use std::collections::BTreeSet;
1472    use std::fs::{create_dir_all, write};
1473    use tempfile::tempdir;
1474
1475    fn test_db() -> LocalDb {
1476        let dir = tempdir().unwrap();
1477        let path = dir.keep().join("test.db");
1478        LocalDb::open_path(&path).unwrap()
1479    }
1480
1481    fn temp_root() -> tempfile::TempDir {
1482        tempdir().unwrap()
1483    }
1484
1485    fn make_row(id: &str, tool: &str, source_path: Option<&str>) -> LocalSessionRow {
1486        LocalSessionRow {
1487            id: id.to_string(),
1488            source_path: source_path.map(String::from),
1489            sync_status: "local_only".to_string(),
1490            last_synced_at: None,
1491            user_id: None,
1492            nickname: None,
1493            team_id: None,
1494            tool: tool.to_string(),
1495            agent_provider: None,
1496            agent_model: None,
1497            title: Some("test".to_string()),
1498            description: None,
1499            tags: None,
1500            created_at: "2024-01-01T00:00:00Z".to_string(),
1501            uploaded_at: None,
1502            message_count: 0,
1503            user_message_count: 0,
1504            task_count: 0,
1505            event_count: 0,
1506            duration_seconds: 0,
1507            total_input_tokens: 0,
1508            total_output_tokens: 0,
1509            git_remote: None,
1510            git_branch: None,
1511            git_commit: None,
1512            git_repo_name: None,
1513            pr_number: None,
1514            pr_url: None,
1515            working_directory: None,
1516            files_modified: None,
1517            files_read: None,
1518            has_errors: false,
1519            max_active_agents: 1,
1520        }
1521    }
1522
1523    #[test]
1524    fn test_open_and_schema() {
1525        let _db = test_db();
1526    }
1527
1528    #[test]
1529    fn test_open_backfills_legacy_sessions_columns() {
1530        let dir = tempfile::tempdir().unwrap();
1531        let path = dir.path().join("legacy.db");
1532        {
1533            let conn = Connection::open(&path).unwrap();
1534            conn.execute_batch(
1535                "CREATE TABLE sessions (id TEXT PRIMARY KEY);
1536                 INSERT INTO sessions (id) VALUES ('legacy-1');",
1537            )
1538            .unwrap();
1539        }
1540
1541        let db = LocalDb::open_path(&path).unwrap();
1542        let rows = db.list_sessions(&LocalSessionFilter::default()).unwrap();
1543        assert_eq!(rows.len(), 1);
1544        assert_eq!(rows[0].id, "legacy-1");
1545        assert_eq!(rows[0].user_message_count, 0);
1546    }
1547
1548    #[test]
1549    fn test_open_rotates_incompatible_legacy_schema() {
1550        let dir = tempfile::tempdir().unwrap();
1551        let path = dir.path().join("broken.db");
1552        {
1553            let conn = Connection::open(&path).unwrap();
1554            conn.execute_batch("CREATE VIEW sessions AS SELECT 'x' AS id;")
1555                .unwrap();
1556        }
1557
1558        let db = LocalDb::open_path(&path).unwrap();
1559        let rows = db.list_sessions(&LocalSessionFilter::default()).unwrap();
1560        assert!(rows.is_empty());
1561
1562        let rotated = std::fs::read_dir(dir.path())
1563            .unwrap()
1564            .filter_map(Result::ok)
1565            .any(|entry| {
1566                let name = entry.file_name();
1567                let name = name.to_string_lossy();
1568                name.starts_with("broken.db.legacy-") && name.ends_with(".bak")
1569            });
1570        assert!(rotated, "expected rotated legacy backup file");
1571    }
1572
1573    #[test]
1574    fn test_is_opencode_child_session() {
1575        let root = temp_root();
1576        let dir = root.path().join("sessions");
1577        create_dir_all(&dir).unwrap();
1578        let parent_session = dir.join("parent.json");
1579        write(
1580            &parent_session,
1581            r#"{"id":"ses_parent","time":{"created":1000,"updated":1000}}"#,
1582        )
1583        .unwrap();
1584        let child_session = dir.join("child.json");
1585        write(
1586            &child_session,
1587            r#"{"id":"ses_child","parentID":"ses_parent","time":{"created":1000,"updated":1000}}"#,
1588        )
1589        .unwrap();
1590
1591        let parent = make_row(
1592            "ses_parent",
1593            "opencode",
1594            Some(parent_session.to_str().unwrap()),
1595        );
1596        let child = make_row(
1597            "ses_child",
1598            "opencode",
1599            Some(child_session.to_str().unwrap()),
1600        );
1601        let codex = make_row("ses_other", "codex", Some(child_session.to_str().unwrap()));
1602
1603        assert!(!is_opencode_child_session(&parent));
1604        assert!(is_opencode_child_session(&child));
1605        assert!(!is_opencode_child_session(&codex));
1606    }
1607
1608    #[test]
1609    fn test_is_opencode_child_session_uses_event_shape_heuristic() {
1610        let child = LocalSessionRow {
1611            id: "sess_child".to_string(),
1612            source_path: None,
1613            sync_status: "local_only".to_string(),
1614            last_synced_at: None,
1615            user_id: None,
1616            nickname: None,
1617            team_id: None,
1618            tool: "opencode".to_string(),
1619            agent_provider: None,
1620            agent_model: None,
1621            title: None,
1622            description: None,
1623            tags: None,
1624            created_at: "2024-01-01T00:00:00Z".to_string(),
1625            uploaded_at: None,
1626            message_count: 1,
1627            user_message_count: 0,
1628            task_count: 4,
1629            event_count: 4,
1630            duration_seconds: 0,
1631            total_input_tokens: 0,
1632            total_output_tokens: 0,
1633            git_remote: None,
1634            git_branch: None,
1635            git_commit: None,
1636            git_repo_name: None,
1637            pr_number: None,
1638            pr_url: None,
1639            working_directory: None,
1640            files_modified: None,
1641            files_read: None,
1642            has_errors: false,
1643            max_active_agents: 1,
1644        };
1645
1646        let parent = LocalSessionRow {
1647            id: "sess_parent".to_string(),
1648            source_path: None,
1649            sync_status: "local_only".to_string(),
1650            last_synced_at: None,
1651            user_id: None,
1652            nickname: None,
1653            team_id: None,
1654            tool: "opencode".to_string(),
1655            agent_provider: None,
1656            agent_model: None,
1657            title: Some("regular".to_string()),
1658            description: None,
1659            tags: None,
1660            created_at: "2024-01-01T00:00:00Z".to_string(),
1661            uploaded_at: None,
1662            message_count: 1,
1663            user_message_count: 1,
1664            task_count: 2,
1665            event_count: 20,
1666            duration_seconds: 0,
1667            total_input_tokens: 0,
1668            total_output_tokens: 0,
1669            git_remote: None,
1670            git_branch: None,
1671            git_commit: None,
1672            git_repo_name: None,
1673            pr_number: None,
1674            pr_url: None,
1675            working_directory: None,
1676            files_modified: None,
1677            files_read: None,
1678            has_errors: false,
1679            max_active_agents: 1,
1680        };
1681
1682        assert!(is_opencode_child_session(&child));
1683        assert!(!is_opencode_child_session(&parent));
1684    }
1685
1686    #[test]
1687    fn test_is_opencode_child_session_with_more_messages_is_hidden_if_task_count_small() {
1688        let child = LocalSessionRow {
1689            id: "sess_child_2".to_string(),
1690            source_path: None,
1691            sync_status: "local_only".to_string(),
1692            last_synced_at: None,
1693            user_id: None,
1694            nickname: None,
1695            team_id: None,
1696            tool: "opencode".to_string(),
1697            agent_provider: None,
1698            agent_model: None,
1699            title: None,
1700            description: None,
1701            tags: None,
1702            created_at: "2024-01-01T00:00:00Z".to_string(),
1703            uploaded_at: None,
1704            message_count: 2,
1705            user_message_count: 0,
1706            task_count: 4,
1707            event_count: 4,
1708            duration_seconds: 0,
1709            total_input_tokens: 0,
1710            total_output_tokens: 0,
1711            git_remote: None,
1712            git_branch: None,
1713            git_commit: None,
1714            git_repo_name: None,
1715            pr_number: None,
1716            pr_url: None,
1717            working_directory: None,
1718            files_modified: None,
1719            files_read: None,
1720            has_errors: false,
1721            max_active_agents: 1,
1722        };
1723
1724        let parent = LocalSessionRow {
1725            id: "sess_parent".to_string(),
1726            source_path: None,
1727            sync_status: "local_only".to_string(),
1728            last_synced_at: None,
1729            user_id: None,
1730            nickname: None,
1731            team_id: None,
1732            tool: "opencode".to_string(),
1733            agent_provider: None,
1734            agent_model: None,
1735            title: Some("regular".to_string()),
1736            description: None,
1737            tags: None,
1738            created_at: "2024-01-01T00:00:00Z".to_string(),
1739            uploaded_at: None,
1740            message_count: 2,
1741            user_message_count: 1,
1742            task_count: 5,
1743            event_count: 20,
1744            duration_seconds: 0,
1745            total_input_tokens: 0,
1746            total_output_tokens: 0,
1747            git_remote: None,
1748            git_branch: None,
1749            git_commit: None,
1750            git_repo_name: None,
1751            pr_number: None,
1752            pr_url: None,
1753            working_directory: None,
1754            files_modified: None,
1755            files_read: None,
1756            has_errors: false,
1757            max_active_agents: 1,
1758        };
1759
1760        assert!(is_opencode_child_session(&child));
1761        assert!(!is_opencode_child_session(&parent));
1762    }
1763
1764    #[test]
1765    fn test_is_opencode_child_session_with_more_messages_but_few_tasks() {
1766        let child = LocalSessionRow {
1767            id: "sess_child_3".to_string(),
1768            source_path: None,
1769            sync_status: "local_only".to_string(),
1770            last_synced_at: None,
1771            user_id: None,
1772            nickname: None,
1773            team_id: None,
1774            tool: "opencode".to_string(),
1775            agent_provider: None,
1776            agent_model: None,
1777            title: None,
1778            description: None,
1779            tags: None,
1780            created_at: "2024-01-01T00:00:00Z".to_string(),
1781            uploaded_at: None,
1782            message_count: 3,
1783            user_message_count: 0,
1784            task_count: 2,
1785            event_count: 6,
1786            duration_seconds: 0,
1787            total_input_tokens: 0,
1788            total_output_tokens: 0,
1789            git_remote: None,
1790            git_branch: None,
1791            git_commit: None,
1792            git_repo_name: None,
1793            pr_number: None,
1794            pr_url: None,
1795            working_directory: None,
1796            files_modified: None,
1797            files_read: None,
1798            has_errors: false,
1799            max_active_agents: 1,
1800        };
1801
1802        assert!(is_opencode_child_session(&child));
1803    }
1804
1805    #[test]
1806    fn test_parse_opencode_parent_session_id_aliases() {
1807        let root = temp_root();
1808        let dir = root.path().join("session-aliases");
1809        create_dir_all(&dir).unwrap();
1810        let child_session = dir.join("child.json");
1811        write(
1812            &child_session,
1813            r#"{"id":"ses_child","parentUUID":"ses_parent","time":{"created":1000,"updated":1000}}"#,
1814        )
1815        .unwrap();
1816        assert_eq!(
1817            parse_opencode_parent_session_id(child_session.to_str().unwrap()).as_deref(),
1818            Some("ses_parent")
1819        );
1820    }
1821
1822    #[test]
1823    fn test_parse_opencode_parent_session_id_nested_metadata() {
1824        let root = temp_root();
1825        let dir = root.path().join("session-nested");
1826        create_dir_all(&dir).unwrap();
1827        let child_session = dir.join("child.json");
1828        write(
1829            &child_session,
1830            r#"{"id":"ses_child","metadata":{"links":{"parentSessionId":"ses_parent","trace":"x"}}}"#,
1831        )
1832        .unwrap();
1833        assert_eq!(
1834            parse_opencode_parent_session_id(child_session.to_str().unwrap()).as_deref(),
1835            Some("ses_parent")
1836        );
1837    }
1838
1839    #[test]
1840    fn test_is_claude_subagent_session() {
1841        let row = make_row(
1842            "ses_parent",
1843            "claude-code",
1844            Some("/Users/test/.claude/projects/foo/subagents/agent-abc.jsonl"),
1845        );
1846        assert!(!is_opencode_child_session(&row));
1847        assert!(is_claude_subagent_session(&row));
1848        assert!(hide_opencode_child_sessions(vec![row]).is_empty());
1849    }
1850
1851    #[test]
1852    fn test_hide_opencode_child_sessions() {
1853        let root = temp_root();
1854        let dir = root.path().join("sessions");
1855        create_dir_all(&dir).unwrap();
1856        let parent_session = dir.join("parent.json");
1857        let child_session = dir.join("child.json");
1858        let orphan_session = dir.join("orphan.json");
1859
1860        write(
1861            &parent_session,
1862            r#"{"id":"ses_parent","time":{"created":1000,"updated":1000}}"#,
1863        )
1864        .unwrap();
1865        write(
1866            &child_session,
1867            r#"{"id":"ses_child","parentID":"ses_parent","time":{"created":1000,"updated":1000}}"#,
1868        )
1869        .unwrap();
1870        write(
1871            &orphan_session,
1872            r#"{"id":"ses_orphan","time":{"created":1000,"updated":1000}}"#,
1873        )
1874        .unwrap();
1875
1876        let rows = vec![
1877            make_row(
1878                "ses_child",
1879                "opencode",
1880                Some(child_session.to_str().unwrap()),
1881            ),
1882            make_row(
1883                "ses_parent",
1884                "opencode",
1885                Some(parent_session.to_str().unwrap()),
1886            ),
1887            {
1888                let mut row = make_row("ses_other", "codex", None);
1889                row.user_message_count = 1;
1890                row
1891            },
1892            make_row(
1893                "ses_orphan",
1894                "opencode",
1895                Some(orphan_session.to_str().unwrap()),
1896            ),
1897        ];
1898
1899        let filtered = hide_opencode_child_sessions(rows);
1900        assert_eq!(filtered.len(), 3);
1901        assert!(filtered.iter().all(|r| r.id != "ses_child"));
1902    }
1903
1904    #[test]
1905    fn test_sync_cursor() {
1906        let db = test_db();
1907        assert_eq!(db.get_sync_cursor("team1").unwrap(), None);
1908        db.set_sync_cursor("team1", "2024-01-01T00:00:00Z").unwrap();
1909        assert_eq!(
1910            db.get_sync_cursor("team1").unwrap(),
1911            Some("2024-01-01T00:00:00Z".to_string())
1912        );
1913        // Update
1914        db.set_sync_cursor("team1", "2024-06-01T00:00:00Z").unwrap();
1915        assert_eq!(
1916            db.get_sync_cursor("team1").unwrap(),
1917            Some("2024-06-01T00:00:00Z".to_string())
1918        );
1919    }
1920
1921    #[test]
1922    fn test_body_cache() {
1923        let db = test_db();
1924        assert_eq!(db.get_cached_body("s1").unwrap(), None);
1925        db.cache_body("s1", b"hello world").unwrap();
1926        assert_eq!(
1927            db.get_cached_body("s1").unwrap(),
1928            Some(b"hello world".to_vec())
1929        );
1930    }
1931
1932    #[test]
1933    fn test_local_migration_files_match_api_local_migrations() {
1934        fn collect_local_sql(dir: PathBuf) -> BTreeSet<String> {
1935            std::fs::read_dir(dir)
1936                .expect("read migrations directory")
1937                .filter_map(Result::ok)
1938                .map(|entry| entry.file_name().to_string_lossy().to_string())
1939                .filter(|name| name.starts_with("local_") && name.ends_with(".sql"))
1940                .collect()
1941        }
1942
1943        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1944        let local_files = collect_local_sql(manifest_dir.join("migrations"));
1945        let api_files = collect_local_sql(manifest_dir.join("../api/migrations"));
1946
1947        assert_eq!(
1948            local_files, api_files,
1949            "local-db local migrations must stay in parity with api local migrations"
1950        );
1951    }
1952
1953    #[test]
1954    fn test_upsert_remote_session() {
1955        let db = test_db();
1956        let summary = RemoteSessionSummary {
1957            id: "remote-1".to_string(),
1958            user_id: Some("u1".to_string()),
1959            nickname: Some("alice".to_string()),
1960            team_id: "t1".to_string(),
1961            tool: "claude-code".to_string(),
1962            agent_provider: None,
1963            agent_model: None,
1964            title: Some("Test session".to_string()),
1965            description: None,
1966            tags: None,
1967            created_at: "2024-01-01T00:00:00Z".to_string(),
1968            uploaded_at: "2024-01-01T01:00:00Z".to_string(),
1969            message_count: 10,
1970            task_count: 2,
1971            event_count: 20,
1972            duration_seconds: 300,
1973            total_input_tokens: 1000,
1974            total_output_tokens: 500,
1975            git_remote: None,
1976            git_branch: None,
1977            git_commit: None,
1978            git_repo_name: None,
1979            pr_number: None,
1980            pr_url: None,
1981            working_directory: None,
1982            files_modified: None,
1983            files_read: None,
1984            has_errors: false,
1985            max_active_agents: 1,
1986        };
1987        db.upsert_remote_session(&summary).unwrap();
1988
1989        let sessions = db.list_sessions(&LocalSessionFilter::default()).unwrap();
1990        assert_eq!(sessions.len(), 1);
1991        assert_eq!(sessions[0].id, "remote-1");
1992        assert_eq!(sessions[0].sync_status, "remote_only");
1993        assert_eq!(sessions[0].nickname, None); // no user in local users table
1994    }
1995
1996    #[test]
1997    fn test_list_filter_by_repo() {
1998        let db = test_db();
1999        // Insert a remote session with team_id
2000        let summary1 = RemoteSessionSummary {
2001            id: "s1".to_string(),
2002            user_id: None,
2003            nickname: None,
2004            team_id: "t1".to_string(),
2005            tool: "claude-code".to_string(),
2006            agent_provider: None,
2007            agent_model: None,
2008            title: Some("Session 1".to_string()),
2009            description: None,
2010            tags: None,
2011            created_at: "2024-01-01T00:00:00Z".to_string(),
2012            uploaded_at: "2024-01-01T01:00:00Z".to_string(),
2013            message_count: 5,
2014            task_count: 0,
2015            event_count: 10,
2016            duration_seconds: 60,
2017            total_input_tokens: 100,
2018            total_output_tokens: 50,
2019            git_remote: None,
2020            git_branch: None,
2021            git_commit: None,
2022            git_repo_name: None,
2023            pr_number: None,
2024            pr_url: None,
2025            working_directory: None,
2026            files_modified: None,
2027            files_read: None,
2028            has_errors: false,
2029            max_active_agents: 1,
2030        };
2031        db.upsert_remote_session(&summary1).unwrap();
2032
2033        // Filter by team
2034        let filter = LocalSessionFilter {
2035            team_id: Some("t1".to_string()),
2036            ..Default::default()
2037        };
2038        assert_eq!(db.list_sessions(&filter).unwrap().len(), 1);
2039
2040        let filter = LocalSessionFilter {
2041            team_id: Some("t999".to_string()),
2042            ..Default::default()
2043        };
2044        assert_eq!(db.list_sessions(&filter).unwrap().len(), 0);
2045    }
2046
2047    // ── Helpers for inserting test sessions ────────────────────────────
2048
2049    fn make_summary(id: &str, tool: &str, title: &str, created_at: &str) -> RemoteSessionSummary {
2050        RemoteSessionSummary {
2051            id: id.to_string(),
2052            user_id: None,
2053            nickname: None,
2054            team_id: "t1".to_string(),
2055            tool: tool.to_string(),
2056            agent_provider: Some("anthropic".to_string()),
2057            agent_model: Some("claude-opus-4-6".to_string()),
2058            title: Some(title.to_string()),
2059            description: None,
2060            tags: None,
2061            created_at: created_at.to_string(),
2062            uploaded_at: created_at.to_string(),
2063            message_count: 5,
2064            task_count: 1,
2065            event_count: 10,
2066            duration_seconds: 300,
2067            total_input_tokens: 1000,
2068            total_output_tokens: 500,
2069            git_remote: None,
2070            git_branch: None,
2071            git_commit: None,
2072            git_repo_name: None,
2073            pr_number: None,
2074            pr_url: None,
2075            working_directory: None,
2076            files_modified: None,
2077            files_read: None,
2078            has_errors: false,
2079            max_active_agents: 1,
2080        }
2081    }
2082
2083    fn seed_sessions(db: &LocalDb) {
2084        // Insert 5 sessions across two tools, ordered by created_at
2085        db.upsert_remote_session(&make_summary(
2086            "s1",
2087            "claude-code",
2088            "First session",
2089            "2024-01-01T00:00:00Z",
2090        ))
2091        .unwrap();
2092        db.upsert_remote_session(&make_summary(
2093            "s2",
2094            "claude-code",
2095            "JWT auth work",
2096            "2024-01-02T00:00:00Z",
2097        ))
2098        .unwrap();
2099        db.upsert_remote_session(&make_summary(
2100            "s3",
2101            "gemini",
2102            "Gemini test",
2103            "2024-01-03T00:00:00Z",
2104        ))
2105        .unwrap();
2106        db.upsert_remote_session(&make_summary(
2107            "s4",
2108            "claude-code",
2109            "Error handling",
2110            "2024-01-04T00:00:00Z",
2111        ))
2112        .unwrap();
2113        db.upsert_remote_session(&make_summary(
2114            "s5",
2115            "claude-code",
2116            "Final polish",
2117            "2024-01-05T00:00:00Z",
2118        ))
2119        .unwrap();
2120    }
2121
2122    // ── list_sessions_log tests ────────────────────────────────────────
2123
2124    #[test]
2125    fn test_log_no_filters() {
2126        let db = test_db();
2127        seed_sessions(&db);
2128        let filter = LogFilter::default();
2129        let results = db.list_sessions_log(&filter).unwrap();
2130        assert_eq!(results.len(), 5);
2131        // Should be ordered by created_at DESC
2132        assert_eq!(results[0].id, "s5");
2133        assert_eq!(results[4].id, "s1");
2134    }
2135
2136    #[test]
2137    fn test_log_filter_by_tool() {
2138        let db = test_db();
2139        seed_sessions(&db);
2140        let filter = LogFilter {
2141            tool: Some("claude-code".to_string()),
2142            ..Default::default()
2143        };
2144        let results = db.list_sessions_log(&filter).unwrap();
2145        assert_eq!(results.len(), 4);
2146        assert!(results.iter().all(|s| s.tool == "claude-code"));
2147    }
2148
2149    #[test]
2150    fn test_log_filter_by_model_wildcard() {
2151        let db = test_db();
2152        seed_sessions(&db);
2153        let filter = LogFilter {
2154            model: Some("claude*".to_string()),
2155            ..Default::default()
2156        };
2157        let results = db.list_sessions_log(&filter).unwrap();
2158        assert_eq!(results.len(), 5); // all have claude-opus model
2159    }
2160
2161    #[test]
2162    fn test_log_filter_since() {
2163        let db = test_db();
2164        seed_sessions(&db);
2165        let filter = LogFilter {
2166            since: Some("2024-01-03T00:00:00Z".to_string()),
2167            ..Default::default()
2168        };
2169        let results = db.list_sessions_log(&filter).unwrap();
2170        assert_eq!(results.len(), 3); // s3, s4, s5
2171    }
2172
2173    #[test]
2174    fn test_log_filter_before() {
2175        let db = test_db();
2176        seed_sessions(&db);
2177        let filter = LogFilter {
2178            before: Some("2024-01-03T00:00:00Z".to_string()),
2179            ..Default::default()
2180        };
2181        let results = db.list_sessions_log(&filter).unwrap();
2182        assert_eq!(results.len(), 2); // s1, s2
2183    }
2184
2185    #[test]
2186    fn test_log_filter_since_and_before() {
2187        let db = test_db();
2188        seed_sessions(&db);
2189        let filter = LogFilter {
2190            since: Some("2024-01-02T00:00:00Z".to_string()),
2191            before: Some("2024-01-04T00:00:00Z".to_string()),
2192            ..Default::default()
2193        };
2194        let results = db.list_sessions_log(&filter).unwrap();
2195        assert_eq!(results.len(), 2); // s2, s3
2196    }
2197
2198    #[test]
2199    fn test_log_filter_grep() {
2200        let db = test_db();
2201        seed_sessions(&db);
2202        let filter = LogFilter {
2203            grep: Some("JWT".to_string()),
2204            ..Default::default()
2205        };
2206        let results = db.list_sessions_log(&filter).unwrap();
2207        assert_eq!(results.len(), 1);
2208        assert_eq!(results[0].id, "s2");
2209    }
2210
2211    #[test]
2212    fn test_log_limit_and_offset() {
2213        let db = test_db();
2214        seed_sessions(&db);
2215        let filter = LogFilter {
2216            limit: Some(2),
2217            offset: Some(1),
2218            ..Default::default()
2219        };
2220        let results = db.list_sessions_log(&filter).unwrap();
2221        assert_eq!(results.len(), 2);
2222        assert_eq!(results[0].id, "s4"); // second most recent
2223        assert_eq!(results[1].id, "s3");
2224    }
2225
2226    #[test]
2227    fn test_log_limit_only() {
2228        let db = test_db();
2229        seed_sessions(&db);
2230        let filter = LogFilter {
2231            limit: Some(3),
2232            ..Default::default()
2233        };
2234        let results = db.list_sessions_log(&filter).unwrap();
2235        assert_eq!(results.len(), 3);
2236    }
2237
2238    #[test]
2239    fn test_list_sessions_limit_offset() {
2240        let db = test_db();
2241        seed_sessions(&db);
2242        let filter = LocalSessionFilter {
2243            limit: Some(2),
2244            offset: Some(1),
2245            ..Default::default()
2246        };
2247        let results = db.list_sessions(&filter).unwrap();
2248        assert_eq!(results.len(), 2);
2249        assert_eq!(results[0].id, "s4");
2250        assert_eq!(results[1].id, "s3");
2251    }
2252
2253    #[test]
2254    fn test_count_sessions_filtered() {
2255        let db = test_db();
2256        seed_sessions(&db);
2257        let count = db
2258            .count_sessions_filtered(&LocalSessionFilter::default())
2259            .unwrap();
2260        assert_eq!(count, 5);
2261    }
2262
2263    #[test]
2264    fn test_list_working_directories_distinct_non_empty() {
2265        let db = test_db();
2266
2267        let mut a = make_summary("wd-1", "claude-code", "One", "2024-01-01T00:00:00Z");
2268        a.working_directory = Some("/tmp/repo-a".to_string());
2269        let mut b = make_summary("wd-2", "claude-code", "Two", "2024-01-02T00:00:00Z");
2270        b.working_directory = Some("/tmp/repo-a".to_string());
2271        let mut c = make_summary("wd-3", "claude-code", "Three", "2024-01-03T00:00:00Z");
2272        c.working_directory = Some("/tmp/repo-b".to_string());
2273        let mut d = make_summary("wd-4", "claude-code", "Four", "2024-01-04T00:00:00Z");
2274        d.working_directory = Some("".to_string());
2275
2276        db.upsert_remote_session(&a).unwrap();
2277        db.upsert_remote_session(&b).unwrap();
2278        db.upsert_remote_session(&c).unwrap();
2279        db.upsert_remote_session(&d).unwrap();
2280
2281        let dirs = db.list_working_directories().unwrap();
2282        assert_eq!(
2283            dirs,
2284            vec!["/tmp/repo-a".to_string(), "/tmp/repo-b".to_string()]
2285        );
2286    }
2287
2288    #[test]
2289    fn test_list_session_tools() {
2290        let db = test_db();
2291        seed_sessions(&db);
2292        let tools = db
2293            .list_session_tools(&LocalSessionFilter::default())
2294            .unwrap();
2295        assert_eq!(tools, vec!["claude-code".to_string(), "gemini".to_string()]);
2296    }
2297
2298    #[test]
2299    fn test_log_combined_filters() {
2300        let db = test_db();
2301        seed_sessions(&db);
2302        let filter = LogFilter {
2303            tool: Some("claude-code".to_string()),
2304            since: Some("2024-01-03T00:00:00Z".to_string()),
2305            limit: Some(1),
2306            ..Default::default()
2307        };
2308        let results = db.list_sessions_log(&filter).unwrap();
2309        assert_eq!(results.len(), 1);
2310        assert_eq!(results[0].id, "s5"); // most recent claude-code after Jan 3
2311    }
2312
2313    // ── Session offset/latest tests ────────────────────────────────────
2314
2315    #[test]
2316    fn test_get_session_by_offset() {
2317        let db = test_db();
2318        seed_sessions(&db);
2319        let row = db.get_session_by_offset(0).unwrap().unwrap();
2320        assert_eq!(row.id, "s5"); // most recent
2321        let row = db.get_session_by_offset(2).unwrap().unwrap();
2322        assert_eq!(row.id, "s3");
2323        assert!(db.get_session_by_offset(10).unwrap().is_none());
2324    }
2325
2326    #[test]
2327    fn test_get_session_by_tool_offset() {
2328        let db = test_db();
2329        seed_sessions(&db);
2330        let row = db
2331            .get_session_by_tool_offset("claude-code", 0)
2332            .unwrap()
2333            .unwrap();
2334        assert_eq!(row.id, "s5");
2335        let row = db
2336            .get_session_by_tool_offset("claude-code", 1)
2337            .unwrap()
2338            .unwrap();
2339        assert_eq!(row.id, "s4");
2340        let row = db.get_session_by_tool_offset("gemini", 0).unwrap().unwrap();
2341        assert_eq!(row.id, "s3");
2342        assert!(db
2343            .get_session_by_tool_offset("gemini", 1)
2344            .unwrap()
2345            .is_none());
2346    }
2347
2348    #[test]
2349    fn test_get_sessions_latest() {
2350        let db = test_db();
2351        seed_sessions(&db);
2352        let rows = db.get_sessions_latest(3).unwrap();
2353        assert_eq!(rows.len(), 3);
2354        assert_eq!(rows[0].id, "s5");
2355        assert_eq!(rows[1].id, "s4");
2356        assert_eq!(rows[2].id, "s3");
2357    }
2358
2359    #[test]
2360    fn test_get_sessions_by_tool_latest() {
2361        let db = test_db();
2362        seed_sessions(&db);
2363        let rows = db.get_sessions_by_tool_latest("claude-code", 2).unwrap();
2364        assert_eq!(rows.len(), 2);
2365        assert_eq!(rows[0].id, "s5");
2366        assert_eq!(rows[1].id, "s4");
2367    }
2368
2369    #[test]
2370    fn test_get_sessions_latest_more_than_available() {
2371        let db = test_db();
2372        seed_sessions(&db);
2373        let rows = db.get_sessions_by_tool_latest("gemini", 10).unwrap();
2374        assert_eq!(rows.len(), 1); // only 1 gemini session
2375    }
2376
2377    #[test]
2378    fn test_session_count() {
2379        let db = test_db();
2380        assert_eq!(db.session_count().unwrap(), 0);
2381        seed_sessions(&db);
2382        assert_eq!(db.session_count().unwrap(), 5);
2383    }
2384
2385    // ── Commit link tests ─────────────────────────────────────────────
2386
2387    #[test]
2388    fn test_link_commit_session() {
2389        let db = test_db();
2390        seed_sessions(&db);
2391        db.link_commit_session("abc123", "s1", Some("/tmp/repo"), Some("main"))
2392            .unwrap();
2393
2394        let commits = db.get_commits_by_session("s1").unwrap();
2395        assert_eq!(commits.len(), 1);
2396        assert_eq!(commits[0].commit_hash, "abc123");
2397        assert_eq!(commits[0].session_id, "s1");
2398        assert_eq!(commits[0].repo_path.as_deref(), Some("/tmp/repo"));
2399        assert_eq!(commits[0].branch.as_deref(), Some("main"));
2400
2401        let sessions = db.get_sessions_by_commit("abc123").unwrap();
2402        assert_eq!(sessions.len(), 1);
2403        assert_eq!(sessions[0].id, "s1");
2404    }
2405
2406    #[test]
2407    fn test_get_sessions_by_commit() {
2408        let db = test_db();
2409        seed_sessions(&db);
2410        // Link multiple sessions to the same commit
2411        db.link_commit_session("abc123", "s1", None, None).unwrap();
2412        db.link_commit_session("abc123", "s2", None, None).unwrap();
2413        db.link_commit_session("abc123", "s3", None, None).unwrap();
2414
2415        let sessions = db.get_sessions_by_commit("abc123").unwrap();
2416        assert_eq!(sessions.len(), 3);
2417        // Ordered by created_at DESC
2418        assert_eq!(sessions[0].id, "s3");
2419        assert_eq!(sessions[1].id, "s2");
2420        assert_eq!(sessions[2].id, "s1");
2421    }
2422
2423    #[test]
2424    fn test_get_commits_by_session() {
2425        let db = test_db();
2426        seed_sessions(&db);
2427        // Link multiple commits to the same session
2428        db.link_commit_session("aaa111", "s1", Some("/repo"), Some("main"))
2429            .unwrap();
2430        db.link_commit_session("bbb222", "s1", Some("/repo"), Some("main"))
2431            .unwrap();
2432        db.link_commit_session("ccc333", "s1", Some("/repo"), Some("feat"))
2433            .unwrap();
2434
2435        let commits = db.get_commits_by_session("s1").unwrap();
2436        assert_eq!(commits.len(), 3);
2437        // All linked to s1
2438        assert!(commits.iter().all(|c| c.session_id == "s1"));
2439    }
2440
2441    #[test]
2442    fn test_duplicate_link_ignored() {
2443        let db = test_db();
2444        seed_sessions(&db);
2445        db.link_commit_session("abc123", "s1", Some("/repo"), Some("main"))
2446            .unwrap();
2447        // Inserting the same link again should not error
2448        db.link_commit_session("abc123", "s1", Some("/repo"), Some("main"))
2449            .unwrap();
2450
2451        let commits = db.get_commits_by_session("s1").unwrap();
2452        assert_eq!(commits.len(), 1);
2453    }
2454
2455    #[test]
2456    fn test_log_filter_by_commit() {
2457        let db = test_db();
2458        seed_sessions(&db);
2459        db.link_commit_session("abc123", "s2", None, None).unwrap();
2460        db.link_commit_session("abc123", "s4", None, None).unwrap();
2461
2462        let filter = LogFilter {
2463            commit: Some("abc123".to_string()),
2464            ..Default::default()
2465        };
2466        let results = db.list_sessions_log(&filter).unwrap();
2467        assert_eq!(results.len(), 2);
2468        assert_eq!(results[0].id, "s4");
2469        assert_eq!(results[1].id, "s2");
2470
2471        // Non-existent commit returns nothing
2472        let filter = LogFilter {
2473            commit: Some("nonexistent".to_string()),
2474            ..Default::default()
2475        };
2476        let results = db.list_sessions_log(&filter).unwrap();
2477        assert_eq!(results.len(), 0);
2478    }
2479}