1use std::collections::{HashMap, HashSet};
7use std::fs::{self, File};
8use std::io::{BufRead, BufReader, Read};
9use std::path::{Component, Path, PathBuf};
10use std::time::UNIX_EPOCH;
11
12use rusqlite::Connection;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::native_store::load_native_store_family;
17use crate::session::percent_decode_path;
18use crate::{Error, Fidelity, Result, Session, SessionFollower};
19
20#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
22#[serde(transparent)]
23pub struct HarnessId(pub String);
24
25impl HarnessId {
26 pub const CLAUDE_CODE: &'static str = "claude-code";
28 pub const CODEX: &'static str = "codex";
30 pub const PI: &'static str = "pi";
32 pub const OPENCODE: &'static str = "opencode";
34 pub const GROK: &'static str = "grok";
36 pub const GEMINI: &'static str = "gemini";
38 pub const GOOSE: &'static str = "goose";
40 pub const SUPERCODE: &'static str = "supercode";
42
43 pub fn new(value: impl Into<String>) -> Self {
45 Self(value.into())
46 }
47
48 pub fn as_str(&self) -> &str {
50 &self.0
51 }
52}
53
54impl From<&str> for HarnessId {
55 fn from(value: &str) -> Self {
56 Self::new(value)
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[serde(tag = "kind", rename_all = "snake_case")]
63pub enum StorageLocator {
64 File {
66 path: PathBuf,
68 },
69 Sqlite {
71 path: PathBuf,
73 selector: String,
75 },
76}
77
78impl StorageLocator {
79 pub fn path(&self) -> &Path {
81 match self {
82 Self::File { path } | Self::Sqlite { path, .. } => path,
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
89pub struct SessionLocator {
90 pub harness: HarnessId,
92 pub session_id: String,
94 pub storage: StorageLocator,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct SessionDescriptor {
101 pub locator: SessionLocator,
104 pub cwd: Option<PathBuf>,
106 pub title: Option<String>,
108 pub updated_at_ms: Option<u64>,
110 pub message_count: Option<usize>,
112 pub model: Option<String>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct DiscoveryPage {
119 pub sessions: Vec<SessionDescriptor>,
121 pub next_cursor: Option<String>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(default)]
128pub struct HarnessHomes {
129 pub claude_code: PathBuf,
131 pub codex: PathBuf,
133 pub pi: PathBuf,
135 pub opencode: PathBuf,
137 pub grok: PathBuf,
139 pub gemini: PathBuf,
141 pub goose: PathBuf,
143 pub supercode: PathBuf,
145}
146
147impl Default for HarnessHomes {
148 fn default() -> Self {
149 let home = std::env::var_os("HOME")
150 .map(PathBuf::from)
151 .unwrap_or_else(|| PathBuf::from("."));
152 let claude_root = std::env::var_os("CLAUDE_CONFIG_DIR")
153 .map(PathBuf::from)
154 .unwrap_or_else(|| home.join(".claude"));
155 let codex_root = std::env::var_os("CODEX_HOME")
156 .map(PathBuf::from)
157 .unwrap_or_else(|| home.join(".codex"));
158 let pi = std::env::var_os("PI_CODING_AGENT_SESSION_DIR")
159 .map(PathBuf::from)
160 .unwrap_or_else(|| {
161 std::env::var_os("PI_CODING_AGENT_DIR")
162 .map(PathBuf::from)
163 .unwrap_or_else(|| home.join(".pi/agent"))
164 .join("sessions")
165 });
166 let opencode = std::env::var_os("OPENCODE_DB")
167 .map(PathBuf::from)
168 .unwrap_or_else(|| {
169 std::env::var_os("XDG_DATA_HOME")
170 .map(PathBuf::from)
171 .unwrap_or_else(|| home.join(".local/share"))
172 .join("opencode")
173 });
174 let grok = std::env::var_os("GROK_HOME")
175 .map(PathBuf::from)
176 .unwrap_or_else(|| home.join(".grok"))
177 .join("sessions");
178 let gemini = std::env::var_os("GEMINI_CLI_HOME")
179 .map(PathBuf::from)
180 .unwrap_or_else(|| home.join(".gemini"));
181 let goose = std::env::var_os("GOOSE_PATH_ROOT")
182 .map(PathBuf::from)
183 .map(|root| root.join("data/sessions/sessions.db"))
184 .unwrap_or_else(|| {
185 #[cfg(target_os = "macos")]
186 {
187 home.join("Library/Application Support/Block/goose/sessions/sessions.db")
188 }
189 #[cfg(target_os = "windows")]
190 {
191 std::env::var_os("APPDATA")
192 .map(PathBuf::from)
193 .unwrap_or_else(|| home.join("AppData/Roaming"))
194 .join("Block/goose/sessions/sessions.db")
195 }
196 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
197 {
198 std::env::var_os("XDG_DATA_HOME")
199 .map(PathBuf::from)
200 .unwrap_or_else(|| home.join(".local/share"))
201 .join("goose/sessions/sessions.db")
202 }
203 });
204 let supercode = std::env::var_os("SUPERCODE_HOME")
205 .map(PathBuf::from)
206 .unwrap_or_else(|| {
207 std::env::var_os("XDG_CONFIG_HOME")
208 .map(PathBuf::from)
209 .unwrap_or_else(|| home.join(".config"))
210 .join("supercode")
211 })
212 .join("sessions");
213 Self {
214 claude_code: claude_root.join("projects"),
215 codex: codex_root.join("sessions"),
216 gemini,
217 goose,
218 supercode,
219 pi,
220 opencode,
221 grok,
222 }
223 }
224}
225
226#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(default)]
229pub struct DiscoveryQuery {
230 pub workspace: Option<PathBuf>,
232 pub harnesses: Vec<HarnessId>,
234 pub homes: HarnessHomes,
236 pub query: Option<String>,
238 pub cursor: Option<String>,
240 pub limit: Option<usize>,
242}
243
244#[derive(Debug, Default, Clone, Copy)]
247pub struct HarnessCatalog;
248
249impl HarnessCatalog {
250 pub fn new() -> Self {
252 Self
253 }
254
255 pub fn discover(&self, query: &DiscoveryQuery) -> Result<Vec<SessionDescriptor>> {
259 Ok(self.discover_page(query)?.sessions)
260 }
261
262 pub fn discover_page(&self, query: &DiscoveryQuery) -> Result<DiscoveryPage> {
264 let selected: HashSet<&str> = if query.harnesses.is_empty() {
265 [
266 HarnessId::CLAUDE_CODE,
267 HarnessId::CODEX,
268 HarnessId::PI,
269 HarnessId::OPENCODE,
270 HarnessId::GROK,
271 HarnessId::GEMINI,
272 HarnessId::GOOSE,
273 HarnessId::SUPERCODE,
274 ]
275 .into_iter()
276 .collect()
277 } else {
278 query.harnesses.iter().map(HarnessId::as_str).collect()
279 };
280 let mut found = Vec::new();
281 if selected.contains(HarnessId::CLAUDE_CODE) {
282 discover_jsonl(
283 &query.homes.claude_code,
284 HarnessId::CLAUDE_CODE,
285 query.workspace.as_deref(),
286 &mut found,
287 );
288 }
289 if selected.contains(HarnessId::CODEX) {
290 discover_jsonl(
291 &query.homes.codex,
292 HarnessId::CODEX,
293 query.workspace.as_deref(),
294 &mut found,
295 );
296 }
297 if selected.contains(HarnessId::PI) {
298 discover_jsonl(
299 &query.homes.pi,
300 HarnessId::PI,
301 query.workspace.as_deref(),
302 &mut found,
303 );
304 }
305 if selected.contains(HarnessId::OPENCODE) {
306 discover_opencode(
307 &query.homes.opencode,
308 query.workspace.as_deref(),
309 &mut found,
310 );
311 }
312 if selected.contains(HarnessId::GROK) {
313 discover_grok(&query.homes.grok, query.workspace.as_deref(), &mut found);
314 }
315 if selected.contains(HarnessId::GEMINI) {
316 discover_gemini(&query.homes.gemini, query.workspace.as_deref(), &mut found);
317 }
318 if selected.contains(HarnessId::GOOSE) {
319 discover_goose(&query.homes.goose, query.workspace.as_deref(), &mut found);
320 }
321 if selected.contains(HarnessId::SUPERCODE) {
322 discover_supercode(
323 &query.homes.supercode,
324 query.workspace.as_deref(),
325 &mut found,
326 );
327 }
328 found.sort_by(|a, b| {
329 b.updated_at_ms
330 .cmp(&a.updated_at_ms)
331 .then_with(|| a.locator.harness.cmp(&b.locator.harness))
332 .then_with(|| a.locator.session_id.cmp(&b.locator.session_id))
333 });
334 if let Some(search) = query
335 .query
336 .as_deref()
337 .map(str::trim)
338 .filter(|q| !q.is_empty())
339 {
340 let search = search.to_lowercase();
341 found.retain(|descriptor| descriptor_matches(descriptor, &search));
342 }
343 let start = match query.cursor.as_deref() {
344 Some(cursor) => {
345 let key = decode_cursor(cursor)?;
346 found
347 .iter()
348 .position(|descriptor| descriptor_cursor_key(descriptor) == key)
349 .map(|index| index + 1)
350 .ok_or_else(|| Error::Other("discovery cursor is stale or invalid".into()))?
351 }
352 None => 0,
353 };
354 let end = query
355 .limit
356 .map(|limit| start.saturating_add(limit).min(found.len()))
357 .unwrap_or(found.len());
358 let sessions = found[start.min(found.len())..end].to_vec();
359 let next_cursor = (end < found.len())
360 .then(|| sessions.last().map(encode_cursor))
361 .flatten();
362 Ok(DiscoveryPage {
363 sessions,
364 next_cursor,
365 })
366 }
367
368 pub fn load(&self, locator: &SessionLocator) -> Result<Session> {
370 self.load_with_fidelity(locator, Fidelity::ByteLossless)
371 }
372
373 pub fn load_with_fidelity(
380 &self,
381 locator: &SessionLocator,
382 fidelity: Fidelity,
383 ) -> Result<Session> {
384 match &locator.storage {
385 StorageLocator::File { path } => {
386 if let Some(session) = load_native_store_family(path)? {
387 Ok(session)
388 } else {
389 Ok(Session::load_with_fidelity(path, fidelity)?)
390 }
391 }
392 StorageLocator::Sqlite { path, selector } => {
393 if locator.harness.as_str() == HarnessId::GOOSE {
394 Ok(Session::from_goose_sqlite(path, selector)?)
395 } else {
396 Ok(Session::from_opencode_sqlite(path, Some(selector))?)
397 }
398 }
399 }
400 }
401
402 #[doc(hidden)]
406 pub fn load_parent_with_fidelity(
407 &self,
408 locator: &SessionLocator,
409 fidelity: Fidelity,
410 ) -> Result<Session> {
411 match &locator.storage {
412 StorageLocator::File { path } => {
413 if let Some(session) = load_native_store_family(path)? {
414 Ok(session)
415 } else {
416 Ok(Session::load_parent_with_fidelity(path, fidelity)?)
417 }
418 }
419 StorageLocator::Sqlite { path, selector } => {
420 if locator.harness.as_str() == HarnessId::GOOSE {
421 Ok(Session::from_goose_sqlite(path, selector)?)
422 } else {
423 Ok(Session::from_opencode_sqlite(path, Some(selector))?)
424 }
425 }
426 }
427 }
428
429 #[doc(hidden)]
432 pub fn load_display_view(
433 &self,
434 locator: &SessionLocator,
435 fidelity: Fidelity,
436 message_limit: usize,
437 ) -> Result<Session> {
438 match &locator.storage {
439 StorageLocator::File { path } => {
440 if let Some(mut session) = load_native_store_family(path)? {
441 if session.messages.len() > message_limit.max(1) {
442 session
443 .messages
444 .drain(..session.messages.len() - message_limit.max(1));
445 }
446 Ok(session)
447 } else {
448 Ok(Session::load_display_view(path, fidelity, message_limit)?)
449 }
450 }
451 StorageLocator::Sqlite { path, selector } => {
452 let mut session = if locator.harness.as_str() == HarnessId::GOOSE {
453 Session::from_goose_sqlite_display(path, selector, message_limit)?
454 } else {
455 Session::from_opencode_sqlite(path, Some(selector))?
456 };
457 if session.messages.len() > message_limit.max(1) {
458 session
459 .messages
460 .drain(..session.messages.len() - message_limit.max(1));
461 }
462 Ok(session)
463 }
464 }
465 }
466
467 pub fn follow(&self, locator: &SessionLocator) -> Result<SessionFollower> {
469 self.follow_with_fidelity(locator, Fidelity::ByteLossless)
470 }
471
472 pub fn follow_with_fidelity(
474 &self,
475 locator: &SessionLocator,
476 fidelity: Fidelity,
477 ) -> Result<SessionFollower> {
478 SessionFollower::open_locator_with_fidelity(locator, fidelity)
479 }
480
481 #[doc(hidden)]
483 pub fn follow_read_view(
484 &self,
485 locator: &SessionLocator,
486 fidelity: Fidelity,
487 include_subagents: bool,
488 message_limit: Option<usize>,
489 max_message_chars: Option<usize>,
490 display_history: bool,
491 ) -> Result<SessionFollower> {
492 SessionFollower::open_locator_with_view(
493 locator,
494 fidelity,
495 include_subagents,
496 message_limit,
497 max_message_chars,
498 display_history,
499 )
500 }
501}
502
503fn descriptor_matches(descriptor: &SessionDescriptor, search: &str) -> bool {
504 [
505 Some(descriptor.locator.harness.as_str()),
506 Some(descriptor.locator.session_id.as_str()),
507 descriptor.title.as_deref(),
508 descriptor.cwd.as_ref().and_then(|path| path.to_str()),
509 descriptor.model.as_deref(),
510 ]
511 .into_iter()
512 .flatten()
513 .any(|value| value.to_lowercase().contains(search))
514}
515
516fn descriptor_cursor_key(descriptor: &SessionDescriptor) -> (Option<u64>, String, String) {
517 (
518 descriptor.updated_at_ms,
519 descriptor.locator.harness.as_str().to_string(),
520 descriptor.locator.session_id.clone(),
521 )
522}
523
524fn encode_cursor(descriptor: &SessionDescriptor) -> String {
525 let json = serde_json::to_vec(&descriptor_cursor_key(descriptor)).unwrap_or_default();
526 let mut encoded = String::with_capacity(json.len() * 2);
527 for byte in json {
528 use std::fmt::Write;
529 let _ = write!(&mut encoded, "{byte:02x}");
530 }
531 encoded
532}
533
534fn decode_cursor(cursor: &str) -> Result<(Option<u64>, String, String)> {
535 if cursor.len() % 2 != 0 {
536 return Err(Error::Other("discovery cursor is invalid".into()));
537 }
538 let bytes = (0..cursor.len())
539 .step_by(2)
540 .map(|index| u8::from_str_radix(&cursor[index..index + 2], 16))
541 .collect::<std::result::Result<Vec<_>, _>>()
542 .map_err(|_| Error::Other("discovery cursor is invalid".into()))?;
543 serde_json::from_slice(&bytes).map_err(|_| Error::Other("discovery cursor is invalid".into()))
544}
545
546#[derive(Default)]
547struct HeaderMeta {
548 session_id: Option<String>,
549 cwd: Option<PathBuf>,
550 model: Option<String>,
551}
552
553fn discover_jsonl(
554 root: &Path,
555 harness: &str,
556 workspace: Option<&Path>,
557 found: &mut Vec<SessionDescriptor>,
558) {
559 let mut files = Vec::new();
560 collect_jsonl(root, harness, &mut files);
561 for path in files {
562 let Ok(meta) = read_header(&path, harness) else {
563 continue;
564 };
565 if workspace.is_some_and(|wanted| {
566 meta.cwd
567 .as_deref()
568 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
569 }) {
570 continue;
571 }
572 let session_id = meta.session_id.unwrap_or_else(|| {
573 path.file_stem()
574 .and_then(|value| value.to_str())
575 .unwrap_or("unknown")
576 .to_string()
577 });
578 found.push(SessionDescriptor {
579 locator: SessionLocator {
580 harness: HarnessId::new(harness),
581 session_id,
582 storage: StorageLocator::File { path: path.clone() },
583 },
584 cwd: meta.cwd,
585 title: None,
586 updated_at_ms: modified_ms(&path),
587 message_count: None,
588 model: meta.model,
589 });
590 }
591}
592
593fn collect_jsonl(root: &Path, harness: &str, out: &mut Vec<PathBuf>) {
594 let Ok(entries) = fs::read_dir(root) else {
595 return;
596 };
597 for entry in entries.flatten() {
598 let Ok(kind) = entry.file_type() else {
599 continue;
600 };
601 let path = entry.path();
602 if kind.is_dir() {
603 if harness == HarnessId::CLAUDE_CODE
604 && path.file_name().and_then(|v| v.to_str()) == Some("subagents")
605 {
606 continue;
607 }
608 collect_jsonl(&path, harness, out);
609 } else if kind.is_file() && path.extension().and_then(|v| v.to_str()) == Some("jsonl") {
610 out.push(path);
611 }
612 }
613}
614
615fn read_header(path: &Path, harness: &str) -> Result<HeaderMeta> {
616 let file = File::open(path)?;
617 let mut result = HeaderMeta::default();
618 let mut bytes = 0usize;
619 for line in BufReader::new(file).lines().take(32) {
620 let line = line?;
621 bytes += line.len();
622 if bytes > 256 * 1024 {
623 break;
624 }
625 let Ok(value) = serde_json::from_str::<Value>(&line) else {
626 continue;
627 };
628 match harness {
629 HarnessId::CLAUDE_CODE => {
630 fill_string(&mut result.session_id, value.get("sessionId"));
631 fill_path(&mut result.cwd, value.get("cwd"));
632 fill_string(
633 &mut result.model,
634 value.get("message").and_then(|v| v.get("model")),
635 );
636 }
637 HarnessId::CODEX => {
638 let payload = value.get("payload").unwrap_or(&Value::Null);
639 if value.get("type").and_then(Value::as_str) == Some("session_meta") {
640 fill_string(&mut result.session_id, payload.get("id"));
641 fill_path(&mut result.cwd, payload.get("cwd"));
642 }
643 if value.get("type").and_then(Value::as_str) == Some("turn_context") {
644 fill_path(&mut result.cwd, payload.get("cwd"));
645 fill_string(&mut result.model, payload.get("model"));
646 }
647 }
648 HarnessId::PI => {
649 if value.get("type").and_then(Value::as_str) == Some("session") {
650 fill_string(&mut result.session_id, value.get("id"));
651 fill_path(&mut result.cwd, value.get("cwd"));
652 }
653 fill_string(
654 &mut result.model,
655 value.get("message").and_then(|v| v.get("model")),
656 );
657 }
658 _ => {}
659 }
660 if result.session_id.is_some() && result.cwd.is_some() && result.model.is_some() {
661 break;
662 }
663 }
664 if result.session_id.is_none() && result.cwd.is_none() {
665 return Err(Error::Other(format!(
666 "{} has no recognizable {harness} session header",
667 path.display()
668 )));
669 }
670 Ok(result)
671}
672
673fn discover_gemini(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
674 let slug_to_cwd = std::fs::read_to_string(root.join("projects.json"))
675 .ok()
676 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
677 .and_then(|value| value.get("projects").and_then(Value::as_object).cloned())
678 .map(|projects| {
679 projects
680 .into_iter()
681 .filter_map(|(cwd, slug)| Some((slug.as_str()?.to_string(), PathBuf::from(cwd))))
682 .collect::<HashMap<_, _>>()
683 })
684 .unwrap_or_default();
685 let mut files = Vec::new();
686 collect_jsonl(&root.join("tmp"), HarnessId::GEMINI, &mut files);
687 let worker_count = std::thread::available_parallelism()
688 .map(usize::from)
689 .unwrap_or(4)
690 .clamp(1, 8)
691 .min(files.len().max(1));
692 let chunk_size = files.len().max(1).div_ceil(worker_count);
693 let discovered = std::thread::scope(|scope| {
694 files
695 .chunks(chunk_size)
696 .map(|paths| {
697 scope.spawn(|| {
698 paths
699 .iter()
700 .filter_map(|path| gemini_descriptor(path, &slug_to_cwd, workspace))
701 .collect::<Vec<_>>()
702 })
703 })
704 .collect::<Vec<_>>()
705 .into_iter()
706 .flat_map(|worker| {
707 worker
708 .join()
709 .expect("Gemini discovery worker must not panic")
710 })
711 .collect::<Vec<_>>()
712 });
713 found.extend(discovered);
714}
715
716fn gemini_descriptor(
717 path: &Path,
718 slug_to_cwd: &HashMap<String, PathBuf>,
719 workspace: Option<&Path>,
720) -> Option<SessionDescriptor> {
721 if path
722 .parent()
723 .and_then(Path::file_name)
724 .and_then(|name| name.to_str())
725 != Some("chats")
726 {
727 return None;
728 }
729 let slug = path
730 .parent()
731 .and_then(Path::parent)
732 .and_then(Path::file_name)
733 .and_then(|name| name.to_str());
734 let cwd = slug.and_then(|slug| slug_to_cwd.get(slug)).cloned();
735 if workspace.is_some_and(|wanted| {
736 cwd.as_deref()
737 .is_none_or(|actual| !recorded_cwd_matches(actual, wanted))
738 }) {
739 return None;
740 }
741
742 let file = File::open(path).ok()?;
746 let mut reader = BufReader::new(file.take(64 * 1024));
747 let mut header = String::new();
748 reader.read_line(&mut header).ok()?;
749 let header = serde_json::from_str::<Value>(&header).ok()?;
750 let session_id = header.get("sessionId")?.as_str()?.to_string();
751 let mut model = None;
752 let mut title = None;
753 for line in reader
754 .take(4 * 1024)
755 .lines()
756 .map_while(std::result::Result::ok)
757 {
758 let Ok(value) = serde_json::from_str::<Value>(&line) else {
759 continue;
760 };
761 let kind = value.get("type").and_then(Value::as_str);
762 if kind != Some("user") && kind != Some("gemini") {
763 continue;
764 }
765 if model.is_none() {
766 model = value
767 .get("model")
768 .and_then(Value::as_str)
769 .map(str::to_string);
770 }
771 if title.is_none() && kind == Some("user") {
772 title = display_text(value.get("content")).filter(|text| !text.is_empty());
773 }
774 if model.is_some() && title.is_some() {
775 break;
776 }
777 }
778 Some(SessionDescriptor {
779 locator: SessionLocator {
780 harness: HarnessId::from(HarnessId::GEMINI),
781 session_id,
782 storage: StorageLocator::File {
783 path: path.to_path_buf(),
784 },
785 },
786 cwd,
787 title: title.map(|title| truncate_title(&title)),
788 updated_at_ms: modified_ms(path),
789 message_count: None,
790 model,
791 })
792}
793
794fn display_text(content: Option<&Value>) -> Option<String> {
795 match content? {
796 Value::String(text) => Some(text.clone()),
797 Value::Array(parts) => Some(
798 parts
799 .iter()
800 .filter_map(|part| part.get("text").and_then(Value::as_str))
801 .collect::<Vec<_>>()
802 .join(" ")
803 .trim()
804 .to_string(),
805 ),
806 _ => None,
807 }
808}
809
810fn truncate_title(title: &str) -> String {
811 const MAX_CHARS: usize = 120;
812 let mut value = title.chars().take(MAX_CHARS).collect::<String>();
813 if title.chars().count() > MAX_CHARS {
814 value.push('…');
815 }
816 value
817}
818
819fn discover_supercode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
820 for info in list_native_store(root) {
821 let path = if info.archived {
822 root.join("archived").join(format!("{}.jsonl", info.name))
823 } else {
824 root.join(format!("{}.jsonl", info.name))
825 };
826 let loaded = workspace
827 .is_some()
828 .then(|| load_native_store_family(&path))
829 .transpose()
830 .ok()
831 .flatten()
832 .flatten();
833 if workspace.is_some_and(|wanted| {
834 loaded
835 .as_ref()
836 .and_then(|session| session.meta.cwd.as_deref())
837 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
838 }) {
839 continue;
840 }
841 let title = (!info.title.trim().is_empty()).then_some(info.title);
842 let updated_at_ms =
843 modified_ms(&path).or_else(|| modified_ms(&path.with_extension("sidecar.jsonl")));
844 found.push(SessionDescriptor {
845 locator: SessionLocator {
846 harness: HarnessId::from(HarnessId::SUPERCODE),
847 session_id: info.name,
848 storage: StorageLocator::File { path: path.clone() },
849 },
850 cwd: loaded.as_ref().and_then(|session| session.meta.cwd.clone()),
851 title,
852 updated_at_ms,
853 message_count: loaded.as_ref().map(|session| session.messages.len()),
854 model: loaded.and_then(|session| session.meta.model),
855 });
856 }
857}
858
859#[derive(Deserialize)]
860struct NativeStoreInfo {
861 name: String,
862 #[serde(default)]
863 title: String,
864 #[serde(skip)]
865 archived: bool,
866}
867
868fn list_native_store(root: &Path) -> Vec<NativeStoreInfo> {
869 let mut sessions = Vec::new();
870 for archived in [false, true] {
871 let directory = if archived {
872 root.join("archived")
873 } else {
874 root.to_path_buf()
875 };
876 let Ok(entries) = fs::read_dir(directory) else {
877 continue;
878 };
879 for entry in entries.flatten() {
880 let path = entry.path();
881 if !path.to_string_lossy().ends_with(".meta.json") {
882 continue;
883 }
884 let Ok(text) = fs::read_to_string(path) else {
885 continue;
886 };
887 let Ok(mut info) = serde_json::from_str::<NativeStoreInfo>(&text) else {
888 continue;
889 };
890 info.archived = archived;
891 sessions.push(info);
892 }
893 }
894 sessions.sort_by(|left, right| left.name.cmp(&right.name));
895 sessions
896}
897
898fn discover_grok(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
899 let Ok(workspaces) = fs::read_dir(root) else {
900 return;
901 };
902 for workspace_entry in workspaces.flatten() {
903 let encoded = workspace_entry.file_name();
904 let Some(cwd) = encoded
905 .to_str()
906 .and_then(percent_decode_path)
907 .map(PathBuf::from)
908 else {
909 continue;
910 };
911 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
912 continue;
913 }
914 let Ok(sessions) = fs::read_dir(workspace_entry.path()) else {
915 continue;
916 };
917 for session_entry in sessions.flatten() {
918 let session_dir = session_entry.path();
919 if !session_dir.is_dir() {
920 continue;
921 }
922 let transcript = session_dir.join("chat_history.jsonl");
923 if !transcript.is_file() {
924 continue;
925 }
926 let Some(session_id) = session_dir
927 .file_name()
928 .and_then(|name| name.to_str())
929 .map(str::to_string)
930 else {
931 continue;
932 };
933 let summary = fs::read_to_string(session_dir.join("summary.json"))
934 .ok()
935 .and_then(|text| serde_json::from_str::<Value>(&text).ok());
936 let title = summary
937 .as_ref()
938 .and_then(|value| value.get("generated_title"))
939 .and_then(Value::as_str)
940 .filter(|title| !title.is_empty())
941 .map(str::to_string);
942 let model = summary
943 .as_ref()
944 .and_then(|value| value.get("current_model_id"))
945 .and_then(Value::as_str)
946 .map(str::to_string);
947 let message_count = summary
948 .as_ref()
949 .and_then(|value| value.get("num_chat_messages"))
950 .and_then(Value::as_u64)
951 .and_then(|count| usize::try_from(count).ok());
952 let updated_at_ms = summary
953 .as_ref()
954 .and_then(|value| value.get("updated_at"))
955 .and_then(Value::as_str)
956 .and_then(crate::sidecar::rfc3339_to_ms)
957 .and_then(|millis| u64::try_from(millis).ok())
958 .or_else(|| modified_ms(&transcript));
959 found.push(SessionDescriptor {
960 locator: SessionLocator {
961 harness: HarnessId::from(HarnessId::GROK),
962 session_id,
963 storage: StorageLocator::File { path: transcript },
964 },
965 cwd: Some(cwd.clone()),
966 title,
967 updated_at_ms,
968 message_count,
969 model,
970 });
971 }
972 }
973}
974
975fn discover_opencode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
976 let mut dbs = Vec::new();
977 if root.is_file() {
978 dbs.push(root.to_path_buf());
979 } else if let Ok(entries) = fs::read_dir(root) {
980 dbs.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
981 path.file_name()
982 .and_then(|v| v.to_str())
983 .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
984 }));
985 }
986 dbs.sort();
987 for db in dbs {
988 let Ok(conn) = Connection::open_with_flags(
989 &db,
990 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
991 ) else {
992 continue;
993 };
994 let has_model = conn.prepare("SELECT model FROM session LIMIT 0").is_ok();
995 let model_column = if has_model { "s.model" } else { "NULL" };
996 let query = format!(
997 "SELECT s.id, s.directory, s.title, s.time_updated, {model_column}, COUNT(m.id) \
998 FROM session s LEFT JOIN message m ON m.session_id = s.id \
999 GROUP BY s.id ORDER BY s.time_updated DESC"
1000 );
1001 let Ok(mut stmt) = conn.prepare(&query) else {
1002 continue;
1003 };
1004 let Ok(rows) = stmt.query_map([], |row| {
1005 Ok((
1006 row.get::<_, String>(0)?,
1007 row.get::<_, String>(1)?,
1008 row.get::<_, String>(2)?,
1009 row.get::<_, i64>(3)?,
1010 row.get::<_, Option<String>>(4)?,
1011 row.get::<_, i64>(5)?,
1012 ))
1013 }) else {
1014 continue;
1015 };
1016 for row in rows.flatten() {
1017 let (id, cwd, title, updated, model, messages) = row;
1018 let cwd = PathBuf::from(cwd);
1019 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
1020 continue;
1021 }
1022 found.push(SessionDescriptor {
1023 locator: SessionLocator {
1024 harness: HarnessId::from(HarnessId::OPENCODE),
1025 session_id: id.clone(),
1026 storage: StorageLocator::Sqlite {
1027 path: db.clone(),
1028 selector: id,
1029 },
1030 },
1031 cwd: Some(cwd),
1032 title: (!title.is_empty()).then_some(title),
1033 updated_at_ms: u64::try_from(updated).ok(),
1034 message_count: usize::try_from(messages).ok(),
1035 model,
1036 });
1037 }
1038 }
1039}
1040
1041fn discover_goose(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1042 let db = if root.is_file() {
1043 root.to_path_buf()
1044 } else if root.join("sessions.db").is_file() {
1045 root.join("sessions.db")
1046 } else {
1047 root.join("sessions/sessions.db")
1048 };
1049 let Ok(connection) = Connection::open_with_flags(
1050 &db,
1051 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1052 ) else {
1053 return;
1054 };
1055 let Ok(mut statement) = connection.prepare(
1056 "SELECT s.id, s.working_dir, s.name, s.updated_at, s.model_config_json, \
1057 COUNT(m.id) \
1058 FROM sessions s LEFT JOIN messages m ON m.session_id = s.id \
1059 WHERE s.archived_at IS NULL \
1060 GROUP BY s.id ORDER BY s.updated_at DESC",
1061 ) else {
1062 return;
1063 };
1064 let Ok(rows) = statement.query_map([], |row| {
1065 Ok((
1066 row.get::<_, String>(0)?,
1067 row.get::<_, String>(1)?,
1068 row.get::<_, String>(2)?,
1069 row.get::<_, String>(3)?,
1070 row.get::<_, Option<String>>(4)?,
1071 row.get::<_, i64>(5)?,
1072 ))
1073 }) else {
1074 return;
1075 };
1076 for row in rows.flatten() {
1077 let (id, cwd, title, updated_at, model_config, message_count) = row;
1078 let cwd = PathBuf::from(cwd);
1079 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
1080 continue;
1081 }
1082 let model = model_config
1083 .as_deref()
1084 .and_then(|value| serde_json::from_str::<Value>(value).ok())
1085 .and_then(|value| {
1086 value
1087 .get("model_name")
1088 .or_else(|| value.get("modelName"))
1089 .and_then(Value::as_str)
1090 .map(str::to_string)
1091 });
1092 let updated_at_ms = crate::sidecar::rfc3339_to_ms(&updated_at)
1093 .or_else(|| {
1094 crate::sidecar::rfc3339_to_ms(&format!("{}Z", updated_at.replace(' ', "T")))
1096 })
1097 .and_then(|value| u64::try_from(value).ok());
1098 found.push(SessionDescriptor {
1099 locator: SessionLocator {
1100 harness: HarnessId::from(HarnessId::GOOSE),
1101 session_id: id.clone(),
1102 storage: StorageLocator::Sqlite {
1103 path: db.clone(),
1104 selector: id,
1105 },
1106 },
1107 cwd: Some(cwd),
1108 title: (!title.trim().is_empty()).then_some(title),
1109 updated_at_ms,
1110 message_count: usize::try_from(message_count).ok(),
1111 model,
1112 });
1113 }
1114}
1115
1116fn fill_string(target: &mut Option<String>, value: Option<&Value>) {
1117 if target.is_none() {
1118 *target = value.and_then(Value::as_str).map(str::to_owned);
1119 }
1120}
1121
1122fn fill_path(target: &mut Option<PathBuf>, value: Option<&Value>) {
1123 if target.is_none() {
1124 *target = value.and_then(Value::as_str).map(PathBuf::from);
1125 }
1126}
1127
1128fn modified_ms(path: &Path) -> Option<u64> {
1129 fs::metadata(path)
1130 .ok()?
1131 .modified()
1132 .ok()?
1133 .duration_since(UNIX_EPOCH)
1134 .ok()
1135 .and_then(|duration| u64::try_from(duration.as_millis()).ok())
1136}
1137
1138fn recorded_cwd_matches(recorded: &Path, wanted: &Path) -> bool {
1144 recorded.is_absolute() && same_path(recorded, wanted)
1145}
1146
1147fn same_path(left: &Path, right: &Path) -> bool {
1148 match (fs::canonicalize(left), fs::canonicalize(right)) {
1149 (Ok(left), Ok(right)) => left == right,
1150 _ => normalize_path(left) == normalize_path(right),
1151 }
1152}
1153
1154fn normalize_path(path: &Path) -> PathBuf {
1155 let absolute = if path.is_absolute() {
1156 path.to_path_buf()
1157 } else {
1158 std::env::current_dir()
1159 .unwrap_or_else(|_| PathBuf::from("."))
1160 .join(path)
1161 };
1162 let mut normalized = PathBuf::new();
1163 for component in absolute.components() {
1164 match component {
1165 Component::CurDir => {}
1166 Component::ParentDir => {
1167 normalized.pop();
1168 }
1169 other => normalized.push(other.as_os_str()),
1170 }
1171 }
1172 normalized
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177 use super::*;
1178 use std::time::{SystemTime, UNIX_EPOCH};
1179
1180 fn temp_dir(label: &str) -> PathBuf {
1181 let nonce = SystemTime::now()
1182 .duration_since(UNIX_EPOCH)
1183 .unwrap()
1184 .as_nanos();
1185 let path = std::env::temp_dir().join(format!(
1186 "supercode-catalog-{label}-{}-{nonce}",
1187 std::process::id()
1188 ));
1189 fs::create_dir_all(&path).unwrap();
1190 path
1191 }
1192
1193 #[test]
1194 fn locator_json_round_trip_preserves_sqlite_selector() {
1195 let locator = SessionLocator {
1196 harness: HarnessId::from(HarnessId::OPENCODE),
1197 session_id: "ses_123".into(),
1198 storage: StorageLocator::Sqlite {
1199 path: PathBuf::from("/tmp/opencode-dev.db"),
1200 selector: "ses_123".into(),
1201 },
1202 };
1203 let encoded = serde_json::to_string(&locator).unwrap();
1204 assert_eq!(
1205 serde_json::from_str::<SessionLocator>(&encoded).unwrap(),
1206 locator
1207 );
1208 }
1209
1210 #[test]
1211 fn discovers_filters_loads_and_follows_three_jsonl_harnesses() {
1212 let root = temp_dir("jsonl");
1213 let workspace = root.join("workspace");
1214 let other = root.join("other");
1215 fs::create_dir_all(&workspace).unwrap();
1216 fs::create_dir_all(&other).unwrap();
1217
1218 let claude = root.join("claude");
1219 let codex = root.join("codex");
1220 let pi = root.join("pi");
1221 fs::create_dir_all(&claude).unwrap();
1222 fs::create_dir_all(&codex).unwrap();
1223 fs::create_dir_all(&pi).unwrap();
1224 fs::write(
1225 claude.join("claude.jsonl"),
1226 format!(
1227 "{{\"type\":\"user\",\"sessionId\":\"cc-1\",\"cwd\":{},\"message\":{{\"role\":\"user\",\"content\":\"hi\"}}}}\n",
1228 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
1229 ),
1230 )
1231 .unwrap();
1232 fs::write(
1233 codex.join("rollout.jsonl"),
1234 format!(
1235 "{{\"timestamp\":\"2026-01-01T00:00:00Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"cx-1\",\"cwd\":{}}}}}\n",
1236 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
1237 ),
1238 )
1239 .unwrap();
1240 fs::write(
1241 pi.join("pi.jsonl"),
1242 format!(
1243 "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-1\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n",
1244 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
1245 ),
1246 )
1247 .unwrap();
1248 fs::write(
1249 pi.join("unrelated.jsonl"),
1250 format!(
1251 "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-2\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n",
1252 serde_json::to_string(&other.to_string_lossy()).unwrap()
1253 ),
1254 )
1255 .unwrap();
1256 fs::write(claude.join("partial.jsonl"), "{truncated").unwrap();
1257
1258 let query = DiscoveryQuery {
1259 workspace: Some(workspace),
1260 homes: HarnessHomes {
1261 claude_code: claude,
1262 codex,
1263 pi,
1264 opencode: root.join("missing-opencode"),
1265 grok: root.join("missing-grok"),
1266 gemini: root.join("missing-gemini"),
1267 goose: root.join("missing-goose"),
1268 supercode: root.join("missing-supercode"),
1269 },
1270 ..DiscoveryQuery::default()
1271 };
1272 let catalog = HarnessCatalog::new();
1273 let found = catalog.discover(&query).unwrap();
1274 assert_eq!(found.len(), 3);
1275 assert_eq!(
1276 found
1277 .iter()
1278 .map(|item| item.locator.harness.as_str())
1279 .collect::<HashSet<_>>(),
1280 HashSet::from([HarnessId::CLAUDE_CODE, HarnessId::CODEX, HarnessId::PI])
1281 );
1282 for descriptor in found {
1283 let loaded = catalog.load(&descriptor.locator).unwrap();
1284 assert_eq!(
1285 loaded.meta.session_id.as_deref(),
1286 Some(descriptor.locator.session_id.as_str())
1287 );
1288 let mut follower = catalog.follow(&descriptor.locator).unwrap();
1289 assert!(matches!(
1290 follower.poll().unwrap(),
1291 Some(crate::SessionWatchEvent::SessionSnapshot { .. })
1292 ));
1293 }
1294 fs::remove_dir_all(root).ok();
1295 }
1296
1297 #[test]
1298 fn discovers_loads_and_follows_opencode_sqlite() {
1299 let db = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1300 .join("../harness/tests/fixtures/opencode_fixture/opencode.db");
1301 let catalog = HarnessCatalog::new();
1302 let found = catalog
1303 .discover(&DiscoveryQuery {
1304 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
1305 homes: HarnessHomes {
1306 opencode: db,
1307 ..HarnessHomes::default()
1308 },
1309 ..DiscoveryQuery::default()
1310 })
1311 .unwrap();
1312 assert!(!found.is_empty());
1313 for descriptor in found {
1314 assert_eq!(descriptor.locator.harness.as_str(), HarnessId::OPENCODE);
1315 assert_eq!(
1316 catalog.load(&descriptor.locator).unwrap().meta.session_id,
1317 Some(descriptor.locator.session_id.clone())
1318 );
1319 assert!(catalog.follow(&descriptor.locator).is_ok());
1320 }
1321 }
1322
1323 #[test]
1324 fn discovers_loads_and_follows_gemini_conversation_records() {
1325 let root = temp_dir("gemini");
1326 let workspace = root.join("workspace");
1327 let chats = root.join("gemini/tmp/demo/chats");
1328 fs::create_dir_all(&workspace).unwrap();
1329 fs::create_dir_all(&chats).unwrap();
1330 fs::write(
1331 root.join("gemini/projects.json"),
1332 serde_json::json!({
1333 "projects": {workspace.to_string_lossy(): "demo"}
1334 })
1335 .to_string(),
1336 )
1337 .unwrap();
1338 let transcript = chats.join("gemini-id.jsonl");
1339 fs::write(
1340 &transcript,
1341 include_str!("../../harness/tests/fixtures/gemini_session.jsonl"),
1342 )
1343 .unwrap();
1344
1345 let catalog = HarnessCatalog::new();
1346 let found = catalog
1347 .discover(&DiscoveryQuery {
1348 harnesses: vec![HarnessId::from(HarnessId::GEMINI)],
1349 homes: HarnessHomes {
1350 gemini: root.join("gemini"),
1351 ..HarnessHomes::default()
1352 },
1353 workspace: Some(workspace.clone()),
1354 ..DiscoveryQuery::default()
1355 })
1356 .unwrap();
1357
1358 assert_eq!(found.len(), 1);
1359 assert_eq!(found[0].cwd.as_deref(), Some(workspace.as_path()));
1360 assert_eq!(found[0].message_count, None);
1361 assert_eq!(found[0].model.as_deref(), Some("gemini-2.5-pro"));
1362 assert_eq!(found[0].title.as_deref(), Some("Inspect the fixture."));
1363 let loaded = catalog.load(&found[0].locator).unwrap();
1364 assert_eq!(
1365 loaded.meta.session_id.as_deref(),
1366 Some("11111111-1111-4111-8111-111111111111")
1367 );
1368 assert_eq!(loaded.messages.len(), 4);
1369 assert!(matches!(
1370 catalog.follow(&found[0].locator).unwrap().poll().unwrap(),
1371 Some(crate::SessionWatchEvent::SessionSnapshot { .. })
1372 ));
1373 fs::remove_dir_all(root).ok();
1374 }
1375
1376 #[test]
1377 fn discovers_native_store_and_pages_search_results() {
1378 let root = temp_dir("supercode");
1379 let store_root = root.join("sessions");
1380 fs::create_dir_all(&store_root).unwrap();
1381 for (name, title) in [
1382 ("alpha", "Alpha planning"),
1383 ("beta", "Beta implementation"),
1384 ("gamma", "Gamma review"),
1385 ] {
1386 fs::write(
1387 store_root.join(format!("{name}.jsonl")),
1388 format!("{{\"role\":\"user\",\"content\":\"{title}\"}}\n"),
1389 )
1390 .unwrap();
1391 fs::write(
1392 store_root.join(format!("{name}.meta.json")),
1393 serde_json::json!({"name": name, "title": title}).to_string(),
1394 )
1395 .unwrap();
1396 }
1397 let catalog = HarnessCatalog::new();
1398 let base = DiscoveryQuery {
1399 harnesses: vec![HarnessId::from(HarnessId::SUPERCODE)],
1400 homes: HarnessHomes {
1401 supercode: store_root,
1402 ..HarnessHomes::default()
1403 },
1404 limit: Some(1),
1405 ..DiscoveryQuery::default()
1406 };
1407
1408 let first = catalog.discover_page(&base).unwrap();
1409 assert_eq!(first.sessions.len(), 1);
1410 assert!(first.next_cursor.is_some());
1411 let second = catalog
1412 .discover_page(&DiscoveryQuery {
1413 cursor: first.next_cursor,
1414 ..base.clone()
1415 })
1416 .unwrap();
1417 assert_eq!(second.sessions.len(), 1);
1418 assert_ne!(
1419 first.sessions[0].locator.session_id,
1420 second.sessions[0].locator.session_id
1421 );
1422 let search = catalog
1423 .discover_page(&DiscoveryQuery {
1424 limit: None,
1425 query: Some("implementation".into()),
1426 ..base
1427 })
1428 .unwrap();
1429 assert_eq!(search.sessions.len(), 1);
1430 assert_eq!(search.sessions[0].locator.session_id, "beta");
1431 assert_eq!(search.sessions[0].message_count, None);
1432 assert_eq!(
1433 catalog
1434 .load(&search.sessions[0].locator)
1435 .unwrap()
1436 .messages
1437 .len(),
1438 1
1439 );
1440 fs::remove_dir_all(root).ok();
1441 }
1442
1443 #[test]
1444 fn discovers_current_opencode_schema_without_a_session_model_column() {
1445 let root = temp_dir("opencode-current");
1446 let db = root.join("opencode.db");
1447 let conn = Connection::open(&db).unwrap();
1448 conn.execute_batch(
1449 "CREATE TABLE session (
1450 id TEXT PRIMARY KEY,
1451 directory TEXT NOT NULL,
1452 title TEXT NOT NULL,
1453 time_updated INTEGER NOT NULL
1454 );
1455 CREATE TABLE message (
1456 id TEXT PRIMARY KEY,
1457 session_id TEXT NOT NULL
1458 );
1459 INSERT INTO session VALUES ('ses_current', '/tmp/work', 'Current', 42);
1460 INSERT INTO message VALUES ('msg_current', 'ses_current');",
1461 )
1462 .unwrap();
1463 drop(conn);
1464
1465 let found = HarnessCatalog::new()
1466 .discover(&DiscoveryQuery {
1467 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
1468 homes: HarnessHomes {
1469 opencode: db,
1470 ..HarnessHomes::default()
1471 },
1472 ..DiscoveryQuery::default()
1473 })
1474 .unwrap();
1475
1476 assert_eq!(found.len(), 1);
1477 assert_eq!(found[0].locator.session_id, "ses_current");
1478 assert_eq!(found[0].message_count, Some(1));
1479 assert_eq!(found[0].model, None);
1480 fs::remove_dir_all(root).ok();
1481 }
1482
1483 #[test]
1484 fn workspace_filter_never_matches_a_relative_recorded_cwd() {
1485 let root = temp_dir("opencode-relative-cwd");
1490 let db = root.join("opencode.db");
1491 let conn = Connection::open(&db).unwrap();
1492 let here = std::env::current_dir().unwrap();
1493 conn.execute_batch(&format!(
1494 "CREATE TABLE session (
1495 id TEXT PRIMARY KEY,
1496 directory TEXT NOT NULL,
1497 title TEXT NOT NULL,
1498 time_updated INTEGER NOT NULL
1499 );
1500 CREATE TABLE message (
1501 id TEXT PRIMARY KEY,
1502 session_id TEXT NOT NULL
1503 );
1504 INSERT INTO session VALUES ('ses_relative', '.', 'Ghost', 41);
1505 INSERT INTO session VALUES ('ses_here', '{}', 'Real', 42);",
1506 here.display()
1507 ))
1508 .unwrap();
1509 drop(conn);
1510
1511 let found = HarnessCatalog::new()
1512 .discover(&DiscoveryQuery {
1513 workspace: Some(here),
1514 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
1515 homes: HarnessHomes {
1516 opencode: db,
1517 ..HarnessHomes::default()
1518 },
1519 ..DiscoveryQuery::default()
1520 })
1521 .unwrap();
1522
1523 assert_eq!(found.len(), 1);
1524 assert_eq!(found[0].locator.session_id, "ses_here");
1525 fs::remove_dir_all(root).ok();
1526 }
1527}