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