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
16const 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#[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#[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
108pub fn is_opencode_child_session(row: &LocalSessionRow) -> bool {
110 if row.tool != "opencode" {
111 return false;
112 }
113
114 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
139pub 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
185pub 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#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
255pub enum LocalSortOrder {
256 #[default]
257 Recent,
258 Popular,
259 Longest,
260}
261
262#[derive(Debug, Clone, Default, PartialEq, Eq)]
264pub enum LocalTimeRange {
265 Hours24,
266 Days7,
267 Days30,
268 #[default]
269 All,
270}
271
272#[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#[derive(Debug, Default)]
308pub struct LogFilter {
309 pub tool: Option<String>,
311 pub model: Option<String>,
313 pub since: Option<String>,
315 pub before: Option<String>,
317 pub touches: Option<String>,
319 pub grep: Option<String>,
321 pub has_errors: Option<bool>,
323 pub working_directory: Option<String>,
325 pub git_repo_name: Option<String>,
327 pub commit: Option<String>,
329 pub limit: Option<u32>,
331 pub offset: Option<u32>,
333}
334
335const 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
341pub struct LocalDb {
345 conn: Mutex<Connection>,
346}
347
348impl LocalDb {
349 pub fn open() -> Result<Self> {
352 let path = default_db_path()?;
353 Self::open_path(&path)
354 }
355
356 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 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 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 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 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 pub fn upsert_remote_session(&self, summary: &RemoteSessionSummary) -> Result<()> {
484 let conn = self.conn();
485 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 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 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 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 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 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; 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1241fn 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 conn.execute_batch("PRAGMA foreign_keys=OFF;")?;
1249
1250 apply_local_migrations(&conn)?;
1251
1252 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 ("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
1407pub 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 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); }
1995
1996 #[test]
1997 fn test_list_filter_by_repo() {
1998 let db = test_db();
1999 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 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 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 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 #[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 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); }
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); }
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); }
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); }
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"); 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"); }
2312
2313 #[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"); 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); }
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 #[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 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 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 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 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 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 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}