1use std::collections::HashSet;
7use std::fs::{self, File};
8use std::io::{BufRead, BufReader};
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::session::percent_decode_path;
17use crate::{Error, Fidelity, Result, Session, SessionFollower};
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
21#[serde(transparent)]
22pub struct HarnessId(pub String);
23
24impl HarnessId {
25 pub const CLAUDE_CODE: &'static str = "claude-code";
27 pub const CODEX: &'static str = "codex";
29 pub const PI: &'static str = "pi";
31 pub const OPENCODE: &'static str = "opencode";
33 pub const GROK: &'static str = "grok";
35
36 pub fn new(value: impl Into<String>) -> Self {
38 Self(value.into())
39 }
40
41 pub fn as_str(&self) -> &str {
43 &self.0
44 }
45}
46
47impl From<&str> for HarnessId {
48 fn from(value: &str) -> Self {
49 Self::new(value)
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(tag = "kind", rename_all = "snake_case")]
56pub enum StorageLocator {
57 File {
59 path: PathBuf,
61 },
62 Sqlite {
64 path: PathBuf,
66 selector: String,
68 },
69}
70
71impl StorageLocator {
72 pub fn path(&self) -> &Path {
74 match self {
75 Self::File { path } | Self::Sqlite { path, .. } => path,
76 }
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
82pub struct SessionLocator {
83 pub harness: HarnessId,
85 pub session_id: String,
87 pub storage: StorageLocator,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct SessionDescriptor {
94 pub locator: SessionLocator,
97 pub cwd: Option<PathBuf>,
99 pub title: Option<String>,
101 pub updated_at_ms: Option<u64>,
103 pub message_count: Option<usize>,
105 pub model: Option<String>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(default)]
112pub struct HarnessHomes {
113 pub claude_code: PathBuf,
115 pub codex: PathBuf,
117 pub pi: PathBuf,
119 pub opencode: PathBuf,
121 pub grok: PathBuf,
123}
124
125impl Default for HarnessHomes {
126 fn default() -> Self {
127 let home = std::env::var_os("HOME")
128 .map(PathBuf::from)
129 .unwrap_or_else(|| PathBuf::from("."));
130 let claude_root = std::env::var_os("CLAUDE_CONFIG_DIR")
131 .map(PathBuf::from)
132 .unwrap_or_else(|| home.join(".claude"));
133 let codex_root = std::env::var_os("CODEX_HOME")
134 .map(PathBuf::from)
135 .unwrap_or_else(|| home.join(".codex"));
136 let pi = std::env::var_os("PI_CODING_AGENT_SESSION_DIR")
137 .map(PathBuf::from)
138 .unwrap_or_else(|| {
139 std::env::var_os("PI_CODING_AGENT_DIR")
140 .map(PathBuf::from)
141 .unwrap_or_else(|| home.join(".pi/agent"))
142 .join("sessions")
143 });
144 let opencode = std::env::var_os("OPENCODE_DB")
145 .map(PathBuf::from)
146 .unwrap_or_else(|| {
147 std::env::var_os("XDG_DATA_HOME")
148 .map(PathBuf::from)
149 .unwrap_or_else(|| home.join(".local/share"))
150 .join("opencode")
151 });
152 let grok = std::env::var_os("GROK_HOME")
153 .map(PathBuf::from)
154 .unwrap_or_else(|| home.join(".grok"))
155 .join("sessions");
156 Self {
157 claude_code: claude_root.join("projects"),
158 codex: codex_root.join("sessions"),
159 pi,
160 opencode,
161 grok,
162 }
163 }
164}
165
166#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(default)]
169pub struct DiscoveryQuery {
170 pub workspace: Option<PathBuf>,
172 pub harnesses: Vec<HarnessId>,
174 pub homes: HarnessHomes,
176 pub limit: Option<usize>,
178}
179
180#[derive(Debug, Default, Clone, Copy)]
183pub struct HarnessCatalog;
184
185impl HarnessCatalog {
186 pub fn new() -> Self {
188 Self
189 }
190
191 pub fn discover(&self, query: &DiscoveryQuery) -> Result<Vec<SessionDescriptor>> {
195 let selected: HashSet<&str> = if query.harnesses.is_empty() {
196 [
197 HarnessId::CLAUDE_CODE,
198 HarnessId::CODEX,
199 HarnessId::PI,
200 HarnessId::OPENCODE,
201 HarnessId::GROK,
202 ]
203 .into_iter()
204 .collect()
205 } else {
206 query.harnesses.iter().map(HarnessId::as_str).collect()
207 };
208 let mut found = Vec::new();
209 if selected.contains(HarnessId::CLAUDE_CODE) {
210 discover_jsonl(
211 &query.homes.claude_code,
212 HarnessId::CLAUDE_CODE,
213 query.workspace.as_deref(),
214 &mut found,
215 );
216 }
217 if selected.contains(HarnessId::CODEX) {
218 discover_jsonl(
219 &query.homes.codex,
220 HarnessId::CODEX,
221 query.workspace.as_deref(),
222 &mut found,
223 );
224 }
225 if selected.contains(HarnessId::PI) {
226 discover_jsonl(
227 &query.homes.pi,
228 HarnessId::PI,
229 query.workspace.as_deref(),
230 &mut found,
231 );
232 }
233 if selected.contains(HarnessId::OPENCODE) {
234 discover_opencode(
235 &query.homes.opencode,
236 query.workspace.as_deref(),
237 &mut found,
238 );
239 }
240 if selected.contains(HarnessId::GROK) {
241 discover_grok(&query.homes.grok, query.workspace.as_deref(), &mut found);
242 }
243 found.sort_by(|a, b| {
244 b.updated_at_ms
245 .cmp(&a.updated_at_ms)
246 .then_with(|| a.locator.session_id.cmp(&b.locator.session_id))
247 });
248 if let Some(limit) = query.limit {
249 found.truncate(limit);
250 }
251 Ok(found)
252 }
253
254 pub fn load(&self, locator: &SessionLocator) -> Result<Session> {
256 self.load_with_fidelity(locator, Fidelity::ByteLossless)
257 }
258
259 pub fn load_with_fidelity(
266 &self,
267 locator: &SessionLocator,
268 fidelity: Fidelity,
269 ) -> Result<Session> {
270 match &locator.storage {
271 StorageLocator::File { path } => Session::load_with_fidelity(path, fidelity),
272 StorageLocator::Sqlite { path, selector } => {
273 Session::from_opencode_sqlite(path, Some(selector))
274 }
275 }
276 }
277
278 pub fn follow(&self, locator: &SessionLocator) -> Result<SessionFollower> {
280 self.follow_with_fidelity(locator, Fidelity::ByteLossless)
281 }
282
283 pub fn follow_with_fidelity(
285 &self,
286 locator: &SessionLocator,
287 fidelity: Fidelity,
288 ) -> Result<SessionFollower> {
289 SessionFollower::open_locator_with_fidelity(locator, fidelity)
290 }
291}
292
293#[derive(Default)]
294struct HeaderMeta {
295 session_id: Option<String>,
296 cwd: Option<PathBuf>,
297 model: Option<String>,
298}
299
300fn discover_jsonl(
301 root: &Path,
302 harness: &str,
303 workspace: Option<&Path>,
304 found: &mut Vec<SessionDescriptor>,
305) {
306 let mut files = Vec::new();
307 collect_jsonl(root, harness, &mut files);
308 for path in files {
309 let Ok(meta) = read_header(&path, harness) else {
310 continue;
311 };
312 if workspace.is_some_and(|wanted| {
313 meta.cwd
314 .as_deref()
315 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
316 }) {
317 continue;
318 }
319 let session_id = meta.session_id.unwrap_or_else(|| {
320 path.file_stem()
321 .and_then(|value| value.to_str())
322 .unwrap_or("unknown")
323 .to_string()
324 });
325 found.push(SessionDescriptor {
326 locator: SessionLocator {
327 harness: HarnessId::new(harness),
328 session_id,
329 storage: StorageLocator::File { path: path.clone() },
330 },
331 cwd: meta.cwd,
332 title: None,
333 updated_at_ms: modified_ms(&path),
334 message_count: None,
335 model: meta.model,
336 });
337 }
338}
339
340fn collect_jsonl(root: &Path, harness: &str, out: &mut Vec<PathBuf>) {
341 let Ok(entries) = fs::read_dir(root) else {
342 return;
343 };
344 for entry in entries.flatten() {
345 let Ok(kind) = entry.file_type() else {
346 continue;
347 };
348 let path = entry.path();
349 if kind.is_dir() {
350 if harness == HarnessId::CLAUDE_CODE
351 && path.file_name().and_then(|v| v.to_str()) == Some("subagents")
352 {
353 continue;
354 }
355 collect_jsonl(&path, harness, out);
356 } else if kind.is_file() && path.extension().and_then(|v| v.to_str()) == Some("jsonl") {
357 out.push(path);
358 }
359 }
360}
361
362fn read_header(path: &Path, harness: &str) -> Result<HeaderMeta> {
363 let file = File::open(path)?;
364 let mut result = HeaderMeta::default();
365 let mut bytes = 0usize;
366 for line in BufReader::new(file).lines().take(32) {
367 let line = line?;
368 bytes += line.len();
369 if bytes > 256 * 1024 {
370 break;
371 }
372 let Ok(value) = serde_json::from_str::<Value>(&line) else {
373 continue;
374 };
375 match harness {
376 HarnessId::CLAUDE_CODE => {
377 fill_string(&mut result.session_id, value.get("sessionId"));
378 fill_path(&mut result.cwd, value.get("cwd"));
379 fill_string(
380 &mut result.model,
381 value.get("message").and_then(|v| v.get("model")),
382 );
383 }
384 HarnessId::CODEX => {
385 let payload = value.get("payload").unwrap_or(&Value::Null);
386 if value.get("type").and_then(Value::as_str) == Some("session_meta") {
387 fill_string(&mut result.session_id, payload.get("id"));
388 fill_path(&mut result.cwd, payload.get("cwd"));
389 }
390 if value.get("type").and_then(Value::as_str) == Some("turn_context") {
391 fill_path(&mut result.cwd, payload.get("cwd"));
392 fill_string(&mut result.model, payload.get("model"));
393 }
394 }
395 HarnessId::PI => {
396 if value.get("type").and_then(Value::as_str) == Some("session") {
397 fill_string(&mut result.session_id, value.get("id"));
398 fill_path(&mut result.cwd, value.get("cwd"));
399 }
400 fill_string(
401 &mut result.model,
402 value.get("message").and_then(|v| v.get("model")),
403 );
404 }
405 _ => {}
406 }
407 if result.session_id.is_some() && result.cwd.is_some() && result.model.is_some() {
408 break;
409 }
410 }
411 if result.session_id.is_none() && result.cwd.is_none() {
412 return Err(Error::Other(format!(
413 "{} has no recognizable {harness} session header",
414 path.display()
415 )));
416 }
417 Ok(result)
418}
419
420fn discover_grok(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
421 let Ok(workspaces) = fs::read_dir(root) else {
422 return;
423 };
424 for workspace_entry in workspaces.flatten() {
425 let encoded = workspace_entry.file_name();
426 let Some(cwd) = encoded
427 .to_str()
428 .and_then(percent_decode_path)
429 .map(PathBuf::from)
430 else {
431 continue;
432 };
433 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
434 continue;
435 }
436 let Ok(sessions) = fs::read_dir(workspace_entry.path()) else {
437 continue;
438 };
439 for session_entry in sessions.flatten() {
440 let session_dir = session_entry.path();
441 if !session_dir.is_dir() {
442 continue;
443 }
444 let transcript = session_dir.join("chat_history.jsonl");
445 if !transcript.is_file() {
446 continue;
447 }
448 let Some(session_id) = session_dir
449 .file_name()
450 .and_then(|name| name.to_str())
451 .map(str::to_string)
452 else {
453 continue;
454 };
455 let summary = fs::read_to_string(session_dir.join("summary.json"))
456 .ok()
457 .and_then(|text| serde_json::from_str::<Value>(&text).ok());
458 let title = summary
459 .as_ref()
460 .and_then(|value| value.get("generated_title"))
461 .and_then(Value::as_str)
462 .filter(|title| !title.is_empty())
463 .map(str::to_string);
464 let model = summary
465 .as_ref()
466 .and_then(|value| value.get("current_model_id"))
467 .and_then(Value::as_str)
468 .map(str::to_string);
469 let message_count = summary
470 .as_ref()
471 .and_then(|value| value.get("num_chat_messages"))
472 .and_then(Value::as_u64)
473 .and_then(|count| usize::try_from(count).ok());
474 let updated_at_ms = summary
475 .as_ref()
476 .and_then(|value| value.get("updated_at"))
477 .and_then(Value::as_str)
478 .and_then(crate::sidecar::rfc3339_to_ms)
479 .and_then(|millis| u64::try_from(millis).ok())
480 .or_else(|| modified_ms(&transcript));
481 found.push(SessionDescriptor {
482 locator: SessionLocator {
483 harness: HarnessId::from(HarnessId::GROK),
484 session_id,
485 storage: StorageLocator::File { path: transcript },
486 },
487 cwd: Some(cwd.clone()),
488 title,
489 updated_at_ms,
490 message_count,
491 model,
492 });
493 }
494 }
495}
496
497fn discover_opencode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
498 let mut dbs = Vec::new();
499 if root.is_file() {
500 dbs.push(root.to_path_buf());
501 } else if let Ok(entries) = fs::read_dir(root) {
502 dbs.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
503 path.file_name()
504 .and_then(|v| v.to_str())
505 .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
506 }));
507 }
508 dbs.sort();
509 for db in dbs {
510 let Ok(conn) = Connection::open_with_flags(
511 &db,
512 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
513 ) else {
514 continue;
515 };
516 let has_model = conn.prepare("SELECT model FROM session LIMIT 0").is_ok();
517 let model_column = if has_model { "s.model" } else { "NULL" };
518 let query = format!(
519 "SELECT s.id, s.directory, s.title, s.time_updated, {model_column}, COUNT(m.id) \
520 FROM session s LEFT JOIN message m ON m.session_id = s.id \
521 GROUP BY s.id ORDER BY s.time_updated DESC"
522 );
523 let Ok(mut stmt) = conn.prepare(&query) else {
524 continue;
525 };
526 let Ok(rows) = stmt.query_map([], |row| {
527 Ok((
528 row.get::<_, String>(0)?,
529 row.get::<_, String>(1)?,
530 row.get::<_, String>(2)?,
531 row.get::<_, i64>(3)?,
532 row.get::<_, Option<String>>(4)?,
533 row.get::<_, i64>(5)?,
534 ))
535 }) else {
536 continue;
537 };
538 for row in rows.flatten() {
539 let (id, cwd, title, updated, model, messages) = row;
540 let cwd = PathBuf::from(cwd);
541 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
542 continue;
543 }
544 found.push(SessionDescriptor {
545 locator: SessionLocator {
546 harness: HarnessId::from(HarnessId::OPENCODE),
547 session_id: id.clone(),
548 storage: StorageLocator::Sqlite {
549 path: db.clone(),
550 selector: id,
551 },
552 },
553 cwd: Some(cwd),
554 title: (!title.is_empty()).then_some(title),
555 updated_at_ms: u64::try_from(updated).ok(),
556 message_count: usize::try_from(messages).ok(),
557 model,
558 });
559 }
560 }
561}
562
563fn fill_string(target: &mut Option<String>, value: Option<&Value>) {
564 if target.is_none() {
565 *target = value.and_then(Value::as_str).map(str::to_owned);
566 }
567}
568
569fn fill_path(target: &mut Option<PathBuf>, value: Option<&Value>) {
570 if target.is_none() {
571 *target = value.and_then(Value::as_str).map(PathBuf::from);
572 }
573}
574
575fn modified_ms(path: &Path) -> Option<u64> {
576 fs::metadata(path)
577 .ok()?
578 .modified()
579 .ok()?
580 .duration_since(UNIX_EPOCH)
581 .ok()
582 .and_then(|duration| u64::try_from(duration.as_millis()).ok())
583}
584
585fn recorded_cwd_matches(recorded: &Path, wanted: &Path) -> bool {
591 recorded.is_absolute() && same_path(recorded, wanted)
592}
593
594fn same_path(left: &Path, right: &Path) -> bool {
595 match (fs::canonicalize(left), fs::canonicalize(right)) {
596 (Ok(left), Ok(right)) => left == right,
597 _ => normalize_path(left) == normalize_path(right),
598 }
599}
600
601fn normalize_path(path: &Path) -> PathBuf {
602 let absolute = if path.is_absolute() {
603 path.to_path_buf()
604 } else {
605 std::env::current_dir()
606 .unwrap_or_else(|_| PathBuf::from("."))
607 .join(path)
608 };
609 let mut normalized = PathBuf::new();
610 for component in absolute.components() {
611 match component {
612 Component::CurDir => {}
613 Component::ParentDir => {
614 normalized.pop();
615 }
616 other => normalized.push(other.as_os_str()),
617 }
618 }
619 normalized
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use std::time::{SystemTime, UNIX_EPOCH};
626
627 fn temp_dir(label: &str) -> PathBuf {
628 let nonce = SystemTime::now()
629 .duration_since(UNIX_EPOCH)
630 .unwrap()
631 .as_nanos();
632 let path = std::env::temp_dir().join(format!(
633 "supercode-catalog-{label}-{}-{nonce}",
634 std::process::id()
635 ));
636 fs::create_dir_all(&path).unwrap();
637 path
638 }
639
640 #[test]
641 fn locator_json_round_trip_preserves_sqlite_selector() {
642 let locator = SessionLocator {
643 harness: HarnessId::from(HarnessId::OPENCODE),
644 session_id: "ses_123".into(),
645 storage: StorageLocator::Sqlite {
646 path: PathBuf::from("/tmp/opencode-dev.db"),
647 selector: "ses_123".into(),
648 },
649 };
650 let encoded = serde_json::to_string(&locator).unwrap();
651 assert_eq!(
652 serde_json::from_str::<SessionLocator>(&encoded).unwrap(),
653 locator
654 );
655 }
656
657 #[test]
658 fn discovers_filters_loads_and_follows_three_jsonl_harnesses() {
659 let root = temp_dir("jsonl");
660 let workspace = root.join("workspace");
661 let other = root.join("other");
662 fs::create_dir_all(&workspace).unwrap();
663 fs::create_dir_all(&other).unwrap();
664
665 let claude = root.join("claude");
666 let codex = root.join("codex");
667 let pi = root.join("pi");
668 fs::create_dir_all(&claude).unwrap();
669 fs::create_dir_all(&codex).unwrap();
670 fs::create_dir_all(&pi).unwrap();
671 fs::write(
672 claude.join("claude.jsonl"),
673 format!(
674 "{{\"type\":\"user\",\"sessionId\":\"cc-1\",\"cwd\":{},\"message\":{{\"role\":\"user\",\"content\":\"hi\"}}}}\n",
675 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
676 ),
677 )
678 .unwrap();
679 fs::write(
680 codex.join("rollout.jsonl"),
681 format!(
682 "{{\"timestamp\":\"2026-01-01T00:00:00Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"cx-1\",\"cwd\":{}}}}}\n",
683 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
684 ),
685 )
686 .unwrap();
687 fs::write(
688 pi.join("pi.jsonl"),
689 format!(
690 "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-1\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n",
691 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
692 ),
693 )
694 .unwrap();
695 fs::write(
696 pi.join("unrelated.jsonl"),
697 format!(
698 "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-2\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n",
699 serde_json::to_string(&other.to_string_lossy()).unwrap()
700 ),
701 )
702 .unwrap();
703 fs::write(claude.join("partial.jsonl"), "{truncated").unwrap();
704
705 let query = DiscoveryQuery {
706 workspace: Some(workspace),
707 homes: HarnessHomes {
708 claude_code: claude,
709 codex,
710 pi,
711 opencode: root.join("missing-opencode"),
712 grok: root.join("missing-grok"),
713 },
714 ..DiscoveryQuery::default()
715 };
716 let catalog = HarnessCatalog::new();
717 let found = catalog.discover(&query).unwrap();
718 assert_eq!(found.len(), 3);
719 assert_eq!(
720 found
721 .iter()
722 .map(|item| item.locator.harness.as_str())
723 .collect::<HashSet<_>>(),
724 HashSet::from([HarnessId::CLAUDE_CODE, HarnessId::CODEX, HarnessId::PI])
725 );
726 for descriptor in found {
727 let loaded = catalog.load(&descriptor.locator).unwrap();
728 assert_eq!(
729 loaded.meta.session_id.as_deref(),
730 Some(descriptor.locator.session_id.as_str())
731 );
732 let mut follower = catalog.follow(&descriptor.locator).unwrap();
733 assert!(matches!(
734 follower.poll().unwrap(),
735 Some(crate::SessionWatchEvent::SessionSnapshot { .. })
736 ));
737 }
738 fs::remove_dir_all(root).ok();
739 }
740
741 #[test]
742 fn discovers_loads_and_follows_opencode_sqlite() {
743 let db = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
744 .join("tests/fixtures/opencode_fixture/opencode.db");
745 let catalog = HarnessCatalog::new();
746 let found = catalog
747 .discover(&DiscoveryQuery {
748 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
749 homes: HarnessHomes {
750 opencode: db,
751 ..HarnessHomes::default()
752 },
753 ..DiscoveryQuery::default()
754 })
755 .unwrap();
756 assert!(!found.is_empty());
757 for descriptor in found {
758 assert_eq!(descriptor.locator.harness.as_str(), HarnessId::OPENCODE);
759 assert_eq!(
760 catalog.load(&descriptor.locator).unwrap().meta.session_id,
761 Some(descriptor.locator.session_id.clone())
762 );
763 assert!(catalog.follow(&descriptor.locator).is_ok());
764 }
765 }
766
767 #[test]
768 fn discovers_current_opencode_schema_without_a_session_model_column() {
769 let root = temp_dir("opencode-current");
770 let db = root.join("opencode.db");
771 let conn = Connection::open(&db).unwrap();
772 conn.execute_batch(
773 "CREATE TABLE session (
774 id TEXT PRIMARY KEY,
775 directory TEXT NOT NULL,
776 title TEXT NOT NULL,
777 time_updated INTEGER NOT NULL
778 );
779 CREATE TABLE message (
780 id TEXT PRIMARY KEY,
781 session_id TEXT NOT NULL
782 );
783 INSERT INTO session VALUES ('ses_current', '/tmp/work', 'Current', 42);
784 INSERT INTO message VALUES ('msg_current', 'ses_current');",
785 )
786 .unwrap();
787 drop(conn);
788
789 let found = HarnessCatalog::new()
790 .discover(&DiscoveryQuery {
791 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
792 homes: HarnessHomes {
793 opencode: db,
794 ..HarnessHomes::default()
795 },
796 ..DiscoveryQuery::default()
797 })
798 .unwrap();
799
800 assert_eq!(found.len(), 1);
801 assert_eq!(found[0].locator.session_id, "ses_current");
802 assert_eq!(found[0].message_count, Some(1));
803 assert_eq!(found[0].model, None);
804 fs::remove_dir_all(root).ok();
805 }
806
807 #[test]
808 fn workspace_filter_never_matches_a_relative_recorded_cwd() {
809 let root = temp_dir("opencode-relative-cwd");
814 let db = root.join("opencode.db");
815 let conn = Connection::open(&db).unwrap();
816 let here = std::env::current_dir().unwrap();
817 conn.execute_batch(&format!(
818 "CREATE TABLE session (
819 id TEXT PRIMARY KEY,
820 directory TEXT NOT NULL,
821 title TEXT NOT NULL,
822 time_updated INTEGER NOT NULL
823 );
824 CREATE TABLE message (
825 id TEXT PRIMARY KEY,
826 session_id TEXT NOT NULL
827 );
828 INSERT INTO session VALUES ('ses_relative', '.', 'Ghost', 41);
829 INSERT INTO session VALUES ('ses_here', '{}', 'Real', 42);",
830 here.display()
831 ))
832 .unwrap();
833 drop(conn);
834
835 let found = HarnessCatalog::new()
836 .discover(&DiscoveryQuery {
837 workspace: Some(here),
838 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
839 homes: HarnessHomes {
840 opencode: db,
841 ..HarnessHomes::default()
842 },
843 ..DiscoveryQuery::default()
844 })
845 .unwrap();
846
847 assert_eq!(found.len(), 1);
848 assert_eq!(found[0].locator.session_id, "ses_here");
849 fs::remove_dir_all(root).ok();
850 }
851}