1use std::io::{Read, Seek, SeekFrom};
10use std::path::Path;
11
12use chrono::Utc;
13use khive_runtime::{KhiveRuntime, RuntimeError};
14use khive_storage::types::{SqlStatement, SqlTxOptions, SqlValue};
15
16use super::parse;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum MirrorSource {
23 ClaudeCode,
25 Codex,
27}
28
29impl MirrorSource {
30 pub fn as_str(self) -> &'static str {
32 match self {
33 MirrorSource::ClaudeCode => "claude_code",
34 MirrorSource::Codex => "codex",
35 }
36 }
37}
38
39#[derive(Debug, Default, Clone, PartialEq)]
41pub struct MirrorStats {
42 pub inserted: u64,
44 pub scanned: u64,
46 pub new_offset: u64,
48}
49
50pub async fn mirror_file(
68 runtime: &KhiveRuntime,
69 path: &Path,
70 start_offset: u64,
71 source: MirrorSource,
72 codex_session_id: Option<&str>,
73) -> Result<MirrorStats, RuntimeError> {
74 let content = read_from_offset(path, start_offset).map_err(|e| {
77 RuntimeError::Internal(format!(
78 "mirror_file: failed to read {:?} at offset {start_offset}: {e}",
79 path
80 ))
81 })?;
82
83 if content.is_empty() {
84 return Ok(MirrorStats {
85 inserted: 0,
86 scanned: 0,
87 new_offset: start_offset,
88 });
89 }
90
91 let last_newline = content
94 .iter()
95 .enumerate()
96 .rev()
97 .find(|(_, &b)| b == b'\n')
98 .map(|(i, _)| i);
99
100 let (complete_bytes, partial_len) = match last_newline {
101 Some(pos) => (pos + 1, content.len() - pos - 1),
102 None => {
103 return Ok(MirrorStats {
105 inserted: 0,
106 scanned: 0,
107 new_offset: start_offset,
108 });
109 }
110 };
111
112 let new_offset = start_offset + complete_bytes as u64;
113 let _ = partial_len; let complete_content = String::from_utf8_lossy(&content[..complete_bytes]);
118
119 let events: Vec<parse::ParsedEvent> = match source {
123 MirrorSource::ClaudeCode => complete_content
124 .split('\n')
125 .filter(|l| !l.is_empty())
126 .filter_map(parse::parse_cc_line)
127 .collect(),
128 MirrorSource::Codex => {
129 let sid = codex_session_id.unwrap_or("");
130 let mut line_start: u64 = start_offset;
131 let mut evs = Vec::new();
132 for raw_line in complete_content.split('\n') {
133 let line_byte_len = raw_line.len() as u64 + 1; if !raw_line.is_empty() {
135 if let Some(ev) = parse::parse_codex_line(raw_line, sid, line_start) {
136 evs.push(ev);
137 }
138 }
139 line_start += line_byte_len;
140 }
141 evs
142 }
143 };
144
145 let scanned = complete_content
146 .split('\n')
147 .filter(|l| !l.is_empty())
148 .count() as u64;
149
150 if events.is_empty() {
151 let _ = write_cursor_only(runtime, path, &None, new_offset).await;
154 return Ok(MirrorStats {
155 inserted: 0,
156 scanned,
157 new_offset,
158 });
159 }
160
161 let now_us = Utc::now().timestamp_micros();
164 let sql = runtime.sql();
165
166 let mut tx = sql
167 .begin_tx(SqlTxOptions::default())
168 .await
169 .map_err(|e| RuntimeError::Internal(format!("mirror_file: begin_tx: {e}")))?;
170
171 let mut inserted: u64 = 0;
172 let mut last_session_id: Option<String> = None;
173
174 for ev in &events {
175 let created_at = if ev.created_at_micros != 0 {
176 ev.created_at_micros
177 } else {
178 now_us
179 };
180
181 tx.execute(SqlStatement {
189 sql: format!(
190 "INSERT INTO sessions \
191 (id, provider_session_id, source, cwd, git_branch, slug, \
192 message_count, first_seen_at, last_seen_at, namespace) \
193 VALUES(?1, ?1, '{}', ?2, ?3, ?4, 0, ?5, ?5, 'local') \
194 ON CONFLICT(id) DO NOTHING",
195 source.as_str()
196 ),
197 params: vec![
198 SqlValue::Text(ev.session_id.clone()),
199 ev.cwd
200 .as_deref()
201 .map(|s| SqlValue::Text(s.to_string()))
202 .unwrap_or(SqlValue::Null),
203 ev.git_branch
204 .as_deref()
205 .map(|s| SqlValue::Text(s.to_string()))
206 .unwrap_or(SqlValue::Null),
207 ev.slug
208 .as_deref()
209 .map(|s| SqlValue::Text(s.to_string()))
210 .unwrap_or(SqlValue::Null),
211 SqlValue::Integer(created_at),
212 ],
213 label: Some("session_mirror_create_session".into()),
214 })
215 .await
216 .map_err(|e| RuntimeError::Internal(format!("mirror_file: session create: {e}")))?;
217
218 let affected = tx
220 .execute(SqlStatement {
221 sql: "INSERT OR IGNORE INTO session_messages \
222 (id, session_id, seq, parent_uuid, is_sidechain, role, \
223 msg_type, text, raw, created_at, namespace) \
224 VALUES(?1, ?2, \
225 (SELECT COALESCE(MAX(seq),-1)+1 FROM session_messages WHERE session_id=?2), \
226 ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'local')"
227 .into(),
228 params: vec![
229 SqlValue::Text(ev.uuid.clone()),
230 SqlValue::Text(ev.session_id.clone()),
231 ev.parent_uuid
232 .as_deref()
233 .map(|s| SqlValue::Text(s.to_string()))
234 .unwrap_or(SqlValue::Null),
235 SqlValue::Integer(i64::from(ev.is_sidechain)),
236 ev.role
237 .as_deref()
238 .map(|s| SqlValue::Text(s.to_string()))
239 .unwrap_or(SqlValue::Null),
240 SqlValue::Text(ev.msg_type.clone()),
241 ev.text
242 .as_deref()
243 .map(|s| SqlValue::Text(s.to_string()))
244 .unwrap_or(SqlValue::Null),
245 SqlValue::Text(ev.raw.clone()),
246 SqlValue::Integer(created_at),
247 ],
248 label: Some("session_mirror_insert_message".into()),
249 })
250 .await
251 .map_err(|e| RuntimeError::Internal(format!("mirror_file: message insert: {e}")))?;
252
253 if affected > 0 {
260 tx.execute(SqlStatement {
261 sql: "UPDATE sessions SET \
262 last_seen_at=MAX(last_seen_at, ?2), \
263 cwd=COALESCE(cwd, ?3), \
264 git_branch=COALESCE(git_branch, ?4), \
265 slug=COALESCE(slug, ?5) \
266 WHERE id=?1"
267 .into(),
268 params: vec![
269 SqlValue::Text(ev.session_id.clone()),
270 SqlValue::Integer(created_at),
271 ev.cwd
272 .as_deref()
273 .map(|s| SqlValue::Text(s.to_string()))
274 .unwrap_or(SqlValue::Null),
275 ev.git_branch
276 .as_deref()
277 .map(|s| SqlValue::Text(s.to_string()))
278 .unwrap_or(SqlValue::Null),
279 ev.slug
280 .as_deref()
281 .map(|s| SqlValue::Text(s.to_string()))
282 .unwrap_or(SqlValue::Null),
283 ],
284 label: Some("session_mirror_touch_session".into()),
285 })
286 .await
287 .map_err(|e| RuntimeError::Internal(format!("mirror_file: session touch: {e}")))?;
288 }
289
290 inserted += affected;
291 last_session_id = Some(ev.session_id.clone());
292 }
293
294 if inserted > 0 {
301 let mut seen_sessions: Vec<String> = events
302 .iter()
303 .map(|e| e.session_id.clone())
304 .collect::<std::collections::HashSet<_>>()
305 .into_iter()
306 .collect();
307 seen_sessions.sort(); for sid in &seen_sessions {
310 tx.execute(SqlStatement {
311 sql: "UPDATE sessions SET message_count=\
312 (SELECT COUNT(*) FROM session_messages WHERE session_id=?1) \
313 WHERE id=?1"
314 .into(),
315 params: vec![SqlValue::Text(sid.clone())],
316 label: Some("session_mirror_refresh_count".into()),
317 })
318 .await
319 .map_err(|e| RuntimeError::Internal(format!("mirror_file: count refresh: {e}")))?;
320 }
321 }
322
323 let path_str = path.to_string_lossy().into_owned();
325 tx.execute(SqlStatement {
326 sql: "INSERT INTO session_mirror_cursor(file_path, session_id, byte_offset, updated_at) \
327 VALUES(?1, ?2, ?3, ?4) \
328 ON CONFLICT(file_path) DO UPDATE SET \
329 session_id=excluded.session_id, \
330 byte_offset=excluded.byte_offset, \
331 updated_at=excluded.updated_at"
332 .into(),
333 params: vec![
334 SqlValue::Text(path_str),
335 last_session_id
336 .as_deref()
337 .map(|s| SqlValue::Text(s.to_string()))
338 .unwrap_or(SqlValue::Null),
339 SqlValue::Integer(new_offset as i64),
340 SqlValue::Integer(now_us),
341 ],
342 label: Some("session_mirror_cursor_upsert".into()),
343 })
344 .await
345 .map_err(|e| RuntimeError::Internal(format!("mirror_file: cursor upsert: {e}")))?;
346
347 tx.commit()
349 .await
350 .map_err(|e| RuntimeError::Internal(format!("mirror_file: commit: {e}")))?;
351
352 Ok(MirrorStats {
353 inserted,
354 scanned,
355 new_offset,
356 })
357}
358
359fn read_from_offset(path: &Path, offset: u64) -> std::io::Result<Vec<u8>> {
363 let mut file = std::fs::File::open(path)?;
364 let file_len = file.metadata()?.len();
365 if offset >= file_len {
366 return Ok(Vec::new());
367 }
368 file.seek(SeekFrom::Start(offset))?;
369 let capacity = (file_len - offset) as usize;
370 let mut buf = Vec::with_capacity(capacity);
371 file.read_to_end(&mut buf)?;
372 Ok(buf)
373}
374
375async fn write_cursor_only(
378 runtime: &KhiveRuntime,
379 path: &Path,
380 session_id: &Option<String>,
381 new_offset: u64,
382) -> Result<(), RuntimeError> {
383 let now_us = Utc::now().timestamp_micros();
384 let path_str = path.to_string_lossy().into_owned();
385 let sql = runtime.sql();
386 let mut w = sql
387 .writer()
388 .await
389 .map_err(|e| RuntimeError::Internal(format!("mirror_file: cursor writer: {e}")))?;
390 w.execute(SqlStatement {
391 sql: "INSERT INTO session_mirror_cursor(file_path, session_id, byte_offset, updated_at) \
392 VALUES(?1, ?2, ?3, ?4) \
393 ON CONFLICT(file_path) DO UPDATE SET \
394 session_id=COALESCE(excluded.session_id, session_mirror_cursor.session_id), \
395 byte_offset=excluded.byte_offset, \
396 updated_at=excluded.updated_at"
397 .into(),
398 params: vec![
399 SqlValue::Text(path_str),
400 session_id
401 .as_deref()
402 .map(|s| SqlValue::Text(s.to_string()))
403 .unwrap_or(SqlValue::Null),
404 SqlValue::Integer(new_offset as i64),
405 SqlValue::Integer(now_us),
406 ],
407 label: Some("session_mirror_cursor_only".into()),
408 })
409 .await
410 .map_err(|e| RuntimeError::Internal(format!("mirror_file: cursor write: {e}")))?;
411 Ok(())
412}
413
414#[cfg(test)]
417mod tests {
418 use std::io::Write;
419 use std::sync::Arc;
420
421 use khive_runtime::{
422 AllowAllGate, BackendId, KhiveRuntime, Namespace, RuntimeConfig, RuntimeError,
423 };
424 use khive_storage::types::{SqlStatement, SqlValue};
425 use tempfile::{NamedTempFile, TempDir};
426
427 use super::*;
428 use crate::vocab::SESSION_SCHEMA_PLAN_STMTS;
429
430 async fn setup() -> (KhiveRuntime, TempDir) {
436 let dir = TempDir::new().expect("tempdir");
437 let db_path = dir.path().join("test.db");
438 let rt = KhiveRuntime::new(RuntimeConfig {
439 db_path: Some(db_path),
440 default_namespace: Namespace::local(),
441 embedding_model: None,
442 additional_embedding_models: vec![],
443 gate: Arc::new(AllowAllGate),
444 packs: vec!["kg".to_string()],
445 backend_id: BackendId::main(),
446 brain_profile: None,
447 visible_namespaces: vec![],
448 allowed_outbound_namespaces: vec![],
449 actor_id: None,
450 })
451 .expect("file-backed runtime");
452 apply_session_schema(&rt).await;
453 (rt, dir)
454 }
455
456 async fn apply_session_schema(rt: &KhiveRuntime) {
457 let sql = rt.sql();
458 let mut w = sql.writer().await.expect("writer");
459 for stmt in &SESSION_SCHEMA_PLAN_STMTS {
460 w.execute_script(stmt.to_string())
461 .await
462 .expect("schema stmt");
463 }
464 }
466
467 async fn count_rows(rt: &KhiveRuntime, table: &str) -> i64 {
469 let sql = rt.sql();
470 let mut r = sql.reader().await.expect("reader");
471 let row = r
472 .query_row(SqlStatement {
473 sql: format!("SELECT COUNT(*) FROM {table}"),
474 params: vec![],
475 label: None,
476 })
477 .await
478 .expect("count query")
479 .expect("count row");
480 match row.columns.first().map(|c| &c.value) {
481 Some(SqlValue::Integer(n)) => *n,
482 _ => 0,
483 }
484 }
485
486 async fn cursor_offset(rt: &KhiveRuntime, path_str: &str) -> Option<i64> {
488 let sql = rt.sql();
489 let mut r = sql.reader().await.expect("reader");
490 let row = r
491 .query_row(SqlStatement {
492 sql: "SELECT byte_offset FROM session_mirror_cursor WHERE file_path=?1".into(),
493 params: vec![SqlValue::Text(path_str.to_string())],
494 label: None,
495 })
496 .await
497 .expect("cursor query")?;
498 match row.columns.first().map(|c| &c.value) {
499 Some(SqlValue::Integer(n)) => Some(*n),
500 _ => None,
501 }
502 }
503
504 fn user_line(uuid: &str, session_id: &str, text: &str) -> String {
505 format!(
506 r#"{{"uuid":"{uuid}","sessionId":"{session_id}","type":"user","timestamp":"2026-06-29T10:00:00Z","message":{{"role":"user","content":"{text}"}}}}"#
507 )
508 }
509
510 fn user_line_no_ts(uuid: &str, session_id: &str, text: &str) -> String {
512 format!(
513 r#"{{"uuid":"{uuid}","sessionId":"{session_id}","type":"user","message":{{"role":"user","content":"{text}"}}}}"#
514 )
515 }
516
517 async fn last_seen_at(rt: &KhiveRuntime, session_id: &str) -> Option<i64> {
519 let sql = rt.sql();
520 let mut r = sql.reader().await.expect("reader");
521 let row = r
522 .query_row(SqlStatement {
523 sql: "SELECT last_seen_at FROM sessions WHERE id=?1".into(),
524 params: vec![SqlValue::Text(session_id.to_string())],
525 label: None,
526 })
527 .await
528 .expect("last_seen query")?;
529 match row.columns.first().map(|c| &c.value) {
530 Some(SqlValue::Integer(n)) => Some(*n),
531 _ => None,
532 }
533 }
534
535 #[tokio::test]
536 async fn test_mirror_three_lines_and_idempotency() {
537 let (rt, _dir) = setup().await;
538
539 let line1 = user_line("uuid-1", "sess-A", "Hello");
541 let line2 = user_line("uuid-2", "sess-A", "World");
542 let line3 = user_line("uuid-3", "sess-A", "Done");
543
544 let mut file = NamedTempFile::new().expect("tmpfile");
545 writeln!(file, "{line1}").unwrap();
546 writeln!(file, "{line2}").unwrap();
547 writeln!(file, "{line3}").unwrap();
548
549 let path = file.path().to_path_buf();
550
551 let stats = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
553 .await
554 .expect("mirror_file first call");
555 assert_eq!(stats.inserted, 3, "should insert 3 new messages");
556 assert_eq!(stats.scanned, 3, "should scan 3 lines");
557 assert!(stats.new_offset > 0, "offset should advance");
558
559 let msg_count = count_rows(&rt, "session_messages").await;
560 assert_eq!(msg_count, 3, "3 messages in DB");
561
562 let session_count = count_rows(&rt, "sessions").await;
563 assert_eq!(session_count, 1, "1 session row");
564
565 let stats2 = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
567 .await
568 .expect("mirror_file second call");
569 assert_eq!(stats2.inserted, 0, "second pass must insert 0 rows");
570 assert_eq!(count_rows(&rt, "session_messages").await, 3);
571
572 let stats3 = mirror_file(&rt, &path, stats.new_offset, MirrorSource::ClaudeCode, None)
574 .await
575 .expect("mirror_file from new_offset");
576 assert_eq!(stats3.inserted, 0, "no new data past advanced offset");
577 assert_eq!(stats3.new_offset, stats.new_offset);
578
579 let stored_offset = cursor_offset(&rt, &path.to_string_lossy()).await;
581 assert!(stored_offset.is_some(), "cursor should be recorded");
582 assert_eq!(stored_offset.unwrap(), stats.new_offset as i64);
583 }
584
585 #[tokio::test]
586 async fn test_partial_trailing_line_not_consumed() {
587 let (rt, _dir) = setup().await;
588
589 let line1 = user_line("uuid-p1", "sess-B", "Complete");
590 let partial = r#"{"uuid":"uuid-p2","sessionId":"sess-B","type":"user""#;
592
593 let mut file = NamedTempFile::new().expect("tmpfile");
594 writeln!(file, "{line1}").unwrap(); write!(file, "{partial}").unwrap(); let path = file.path().to_path_buf();
598 let full_len = std::fs::metadata(&path).unwrap().len();
599
600 let stats = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
601 .await
602 .expect("mirror_file partial");
603
604 assert_eq!(stats.inserted, 1, "only 1 complete line inserted");
606 assert!(
607 stats.new_offset < full_len,
608 "new_offset {new} must be less than file_len {full_len}",
609 new = stats.new_offset
610 );
611
612 let stats2 = mirror_file(&rt, &path, stats.new_offset, MirrorSource::ClaudeCode, None)
614 .await
615 .expect("second call");
616 assert_eq!(
617 stats2.inserted, 0,
618 "partial line must not be consumed on re-poll"
619 );
620 assert_eq!(
621 stats2.new_offset, stats.new_offset,
622 "offset must not advance on partial-only content"
623 );
624 }
625
626 #[tokio::test]
627 async fn test_duplicate_uuid_across_two_calls() {
628 let (rt, _dir) = setup().await;
629
630 let line = user_line("uuid-dup", "sess-C", "First");
631
632 let mut file = NamedTempFile::new().expect("tmpfile");
633 writeln!(file, "{line}").unwrap();
634
635 let path = file.path().to_path_buf();
636
637 let s1 = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
639 .await
640 .unwrap();
641 assert_eq!(s1.inserted, 1);
642
643 writeln!(file, "{line}").unwrap();
645
646 let s2 = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
648 .await
649 .unwrap();
650 assert_eq!(s2.inserted, 0, "duplicate uuid must not be re-inserted");
651 assert_eq!(count_rows(&rt, "session_messages").await, 1);
652
653 let s3 = mirror_file(&rt, &path, s1.new_offset, MirrorSource::ClaudeCode, None)
655 .await
656 .unwrap();
657 assert_eq!(s3.inserted, 0, "incremental dup must also insert 0");
658 }
659
660 #[tokio::test]
661 async fn test_replay_does_not_mutate_session_metadata() {
662 let (rt, _dir) = setup().await;
667
668 let line = user_line_no_ts("uuid-nts", "sess-NTS", "no timestamp here");
669 let mut file = NamedTempFile::new().expect("tmpfile");
670 writeln!(file, "{line}").unwrap();
671 let path = file.path().to_path_buf();
672
673 let s1 = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
674 .await
675 .unwrap();
676 assert_eq!(s1.inserted, 1);
677 let seen_after_first = last_seen_at(&rt, "sess-NTS")
678 .await
679 .expect("session row exists");
680
681 let s2 = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
684 .await
685 .unwrap();
686 assert_eq!(s2.inserted, 0, "replay must insert 0 rows");
687 let seen_after_replay = last_seen_at(&rt, "sess-NTS").await.unwrap();
688 assert_eq!(
689 seen_after_first, seen_after_replay,
690 "replay must not advance last_seen_at for a timestamp-missing event"
691 );
692 }
693
694 #[tokio::test]
695 async fn test_empty_file_is_a_no_op() {
696 let (rt, _dir) = setup().await;
697
698 let file = NamedTempFile::new().expect("tmpfile");
699 let path = file.path().to_path_buf();
700
701 let stats = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
702 .await
703 .unwrap();
704 assert_eq!(stats.inserted, 0);
705 assert_eq!(stats.scanned, 0);
706 assert_eq!(stats.new_offset, 0);
707 }
708
709 #[tokio::test]
710 async fn test_missing_file_returns_error() {
711 let (rt, _dir) = setup().await;
712 let bad_path = std::path::PathBuf::from("/nonexistent/path/session.jsonl");
713 let result = mirror_file(&rt, &bad_path, 0, MirrorSource::ClaudeCode, None).await;
714 assert!(
715 matches!(result, Err(RuntimeError::Internal(_))),
716 "missing file should return Internal error"
717 );
718 }
719
720 fn codex_message_line(role: &str, text: &str) -> String {
726 let block_type = if role == "assistant" {
727 "output_text"
728 } else {
729 "input_text"
730 };
731 format!(
732 r#"{{"type":"response_item","timestamp":"2026-06-30T08:00:00Z","payload":{{"type":"message","role":"{role}","content":[{{"type":"{block_type}","text":"{text}"}}]}}}}"#
733 )
734 }
735
736 fn codex_meta_line(session_id: &str, cwd: &str, branch: &str) -> String {
738 format!(
739 r#"{{"type":"session_meta","timestamp":"2026-06-30T08:00:00Z","payload":{{"id":"{session_id}","cwd":"{cwd}","git":{{"branch":"{branch}","commit_hash":"abc","repository_url":"https://github.com/example/repo"}}}}}}"#
740 )
741 }
742
743 fn codex_event_msg_line() -> String {
745 r#"{"type":"event_msg","timestamp":"2026-06-30T08:00:00Z","payload":{"type":"user_message","content":"should be skipped"}}"#.to_string()
746 }
747
748 #[tokio::test]
749 async fn test_codex_mirror_inserts_with_source_codex() {
750 let (rt, _dir) = setup().await;
751
752 let session_id = "cdx-sess-0001-0001-0001-000000000001";
753 let meta = codex_meta_line(session_id, "/home/lion/proj", "feat-x");
754 let user_msg = codex_message_line("user", "Hello from Codex");
755 let asst_msg = codex_message_line("assistant", "Hello back from Codex");
756 let skip_msg = codex_event_msg_line();
757
758 let mut file = NamedTempFile::new().expect("tmpfile");
759 writeln!(file, "{meta}").unwrap();
760 writeln!(file, "{user_msg}").unwrap();
761 writeln!(file, "{asst_msg}").unwrap();
762 writeln!(file, "{skip_msg}").unwrap();
763
764 let path = file.path().to_path_buf();
765
766 let stats = mirror_file(&rt, &path, 0, MirrorSource::Codex, Some(session_id))
768 .await
769 .expect("codex mirror_file");
770
771 assert_eq!(stats.inserted, 3, "meta + 2 messages inserted");
773 assert_eq!(
774 stats.scanned, 4,
775 "4 lines total (including skipped event_msg)"
776 );
777 assert!(stats.new_offset > 0);
778
779 let sql = rt.sql();
781 let mut r = sql.reader().await.expect("reader");
782 let session_row = r
783 .query_row(SqlStatement {
784 sql: "SELECT source FROM sessions WHERE id=?1".into(),
785 params: vec![SqlValue::Text(session_id.to_string())],
786 label: None,
787 })
788 .await
789 .expect("query ok")
790 .expect("session row must exist");
791 match session_row.columns.first().map(|c| &c.value) {
792 Some(SqlValue::Text(s)) => assert_eq!(s, "codex", "source must be 'codex'"),
793 other => panic!("unexpected source value: {other:?}"),
794 }
795
796 assert_eq!(count_rows(&rt, "session_messages").await, 3);
798
799 let mut r2 = sql.reader().await.expect("reader");
803 let rows = r2
804 .query_all(SqlStatement {
805 sql: "SELECT role, text FROM session_messages \
806 WHERE session_id=?1 AND role IS NOT NULL ORDER BY seq"
807 .into(),
808 params: vec![SqlValue::Text(session_id.to_string())],
809 label: None,
810 })
811 .await
812 .expect("query ok");
813 let texts: Vec<(String, String)> = rows
814 .iter()
815 .map(|row| {
816 let role = match row.get("role") {
817 Some(SqlValue::Text(s)) => s.clone(),
818 other => panic!("unexpected role value: {other:?}"),
819 };
820 let text = match row.get("text") {
821 Some(SqlValue::Text(s)) => s.clone(),
822 other => panic!("unexpected text value: {other:?}"),
823 };
824 (role, text)
825 })
826 .collect();
827 assert_eq!(
828 texts,
829 vec![
830 ("user".to_string(), "Hello from Codex".to_string()),
831 ("assistant".to_string(), "Hello back from Codex".to_string()),
832 ],
833 "input_text/output_text blocks must round-trip to session_messages.text by role"
834 );
835 }
836
837 #[tokio::test]
838 async fn test_codex_event_id_is_stable_and_idempotent() {
839 let (rt, _dir) = setup().await;
842
843 let session_id = "cdx-sess-idem-0001-0001-000000000002";
844 let user_msg = codex_message_line("user", "Idempotency test");
845
846 let mut file = NamedTempFile::new().expect("tmpfile");
847 writeln!(file, "{user_msg}").unwrap();
848
849 let path = file.path().to_path_buf();
850
851 let s1 = mirror_file(&rt, &path, 0, MirrorSource::Codex, Some(session_id))
853 .await
854 .unwrap();
855 assert_eq!(s1.inserted, 1);
856
857 let sql = rt.sql();
859 let mut r = sql.reader().await.expect("reader");
860 let msg_row = r
861 .query_row(SqlStatement {
862 sql: "SELECT id FROM session_messages WHERE session_id=?1".into(),
863 params: vec![SqlValue::Text(session_id.to_string())],
864 label: None,
865 })
866 .await
867 .expect("query ok")
868 .expect("message row must exist");
869 let stored_id = match msg_row.columns.first().map(|c| &c.value) {
870 Some(SqlValue::Text(s)) => s.clone(),
871 other => panic!("unexpected id type: {other:?}"),
872 };
873 let expected_id = format!("{session_id}:0");
875 assert_eq!(
876 stored_id, expected_id,
877 "synthetic uuid must be {{session_id}}:{{offset}}"
878 );
879
880 let s2 = mirror_file(&rt, &path, 0, MirrorSource::Codex, Some(session_id))
882 .await
883 .unwrap();
884 assert_eq!(s2.inserted, 0, "second pass must be idempotent");
885 assert_eq!(count_rows(&rt, "session_messages").await, 1);
886
887 let s3 = mirror_file(
889 &rt,
890 &path,
891 s1.new_offset,
892 MirrorSource::Codex,
893 Some(session_id),
894 )
895 .await
896 .unwrap();
897 assert_eq!(s3.inserted, 0, "incremental pass finds nothing new");
898 }
899
900 #[tokio::test]
901 async fn test_codex_and_cc_are_independent_sessions() {
902 let (rt, _dir) = setup().await;
904
905 let cc_line = user_line("cc-uuid-1", "cc-sess-1", "from claude code");
906 let mut cc_file = NamedTempFile::new().expect("cc tmpfile");
907 writeln!(cc_file, "{cc_line}").unwrap();
908
909 let cdx_session_id = "cdx-sess-coex-0001-0001-000000000003";
910 let cdx_msg = codex_message_line("user", "from codex");
911 let mut cdx_file = NamedTempFile::new().expect("cdx tmpfile");
912 writeln!(cdx_file, "{cdx_msg}").unwrap();
913
914 mirror_file(&rt, cc_file.path(), 0, MirrorSource::ClaudeCode, None)
915 .await
916 .unwrap();
917
918 mirror_file(
919 &rt,
920 cdx_file.path(),
921 0,
922 MirrorSource::Codex,
923 Some(cdx_session_id),
924 )
925 .await
926 .unwrap();
927
928 assert_eq!(count_rows(&rt, "sessions").await, 2);
929 assert_eq!(count_rows(&rt, "session_messages").await, 2);
930
931 let sql = rt.sql();
933 let mut r = sql.reader().await.expect("reader");
934 let rows = r
935 .query_all(SqlStatement {
936 sql: "SELECT source FROM sessions ORDER BY source".into(),
937 params: vec![],
938 label: None,
939 })
940 .await
941 .expect("query ok");
942 let sources: Vec<String> = rows
943 .iter()
944 .filter_map(|row| match row.get("source") {
945 Some(SqlValue::Text(s)) => Some(s.clone()),
946 _ => None,
947 })
948 .collect();
949 assert_eq!(sources, vec!["claude_code", "codex"]);
950 }
951}