1use std::io::{BufRead, Seek, SeekFrom};
39use std::path::{Path, PathBuf};
40
41use chrono::Utc;
42use khive_runtime::{KhiveRuntime, RuntimeError};
43use khive_storage::types::{SqlStatement, SqlValue};
44use khive_storage::SqlWriter;
45
46use super::parse;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum MirrorSource {
58 ClaudeCode,
60 Codex,
62 ChatGptExport,
64}
65
66impl MirrorSource {
67 pub fn as_str(self) -> &'static str {
69 match self {
70 MirrorSource::ClaudeCode => "claude_code",
71 MirrorSource::Codex => "codex",
72 MirrorSource::ChatGptExport => "chatgpt_export",
73 }
74 }
75}
76
77impl From<LineTailSource> for MirrorSource {
78 fn from(source: LineTailSource) -> Self {
79 match source {
80 LineTailSource::ClaudeCode => MirrorSource::ClaudeCode,
81 LineTailSource::Codex => MirrorSource::Codex,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum LineTailSource {
95 ClaudeCode,
97 Codex,
99}
100
101#[derive(Debug, Default, Clone, PartialEq)]
103pub struct MirrorStats {
104 pub inserted: u64,
106 pub scanned: u64,
108 pub new_offset: u64,
110}
111
112const MIRROR_MAX_BYTES_PER_PASS: usize = 8 * 1024 * 1024;
116
117const MIRROR_MAX_EVENTS_PER_PASS: usize = 1024;
119
120const MIRROR_MAX_LINE_BYTES: usize = MIRROR_MAX_BYTES_PER_PASS;
126
127#[derive(Clone, Copy, Debug)]
132struct MirrorLimits {
133 max_bytes_per_pass: usize,
134 max_events_per_pass: usize,
135 max_line_bytes: usize,
136}
137
138impl MirrorLimits {
139 fn production() -> Self {
140 Self {
141 max_bytes_per_pass: MIRROR_MAX_BYTES_PER_PASS,
142 max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
143 max_line_bytes: MIRROR_MAX_LINE_BYTES,
144 }
145 }
146}
147
148pub async fn mirror_file(
166 runtime: &KhiveRuntime,
167 path: &Path,
168 start_offset: u64,
169 source: LineTailSource,
170 codex_session_id: Option<&str>,
171) -> Result<MirrorStats, RuntimeError> {
172 mirror_file_with_limits(
173 runtime,
174 path,
175 start_offset,
176 source,
177 codex_session_id,
178 MirrorLimits::production(),
179 )
180 .await
181}
182
183struct MirrorChunk {
187 events: Vec<parse::ParsedEvent>,
188 scanned: u64,
191 new_offset: u64,
192}
193
194#[derive(Debug)]
196enum LineRead {
197 Eof,
199 Partial,
204 Complete { bytes: usize },
209 Oversized { bytes: usize },
215 OversizedUnterminated { bytes: usize },
226}
227
228fn read_line_bounded(
257 reader: &mut impl BufRead,
258 buf: &mut Vec<u8>,
259 max_line_bytes: usize,
260) -> std::io::Result<LineRead> {
261 let mut total: usize = 0;
262 let mut oversized = false;
263
264 loop {
265 let available = reader.fill_buf()?;
266 if available.is_empty() {
267 return Ok(if total == 0 {
268 LineRead::Eof
269 } else {
270 LineRead::Partial
271 });
272 }
273
274 let newline_pos = available.iter().position(|&b| b == b'\n');
275 let take = newline_pos.map_or(available.len(), |pos| pos + 1);
276
277 if !oversized {
278 if total + take > max_line_bytes {
279 oversized = true;
280 } else {
281 buf.extend_from_slice(&available[..take]);
282 }
283 }
284
285 total += take;
286 reader.consume(take);
287
288 if newline_pos.is_some() {
289 return Ok(if oversized {
290 LineRead::Oversized { bytes: total }
291 } else {
292 LineRead::Complete { bytes: total }
293 });
294 }
295
296 if oversized {
297 return Ok(LineRead::OversizedUnterminated { bytes: total });
302 }
303 }
306}
307
308fn read_bounded_chunk(
319 path: &Path,
320 start_offset: u64,
321 source: LineTailSource,
322 codex_session_id: Option<&str>,
323 limits: MirrorLimits,
324) -> std::io::Result<MirrorChunk> {
325 let mut file = std::fs::File::open(path)?;
326 let file_len = file.metadata()?.len();
327 if start_offset >= file_len {
328 return Ok(MirrorChunk {
329 events: Vec::new(),
330 scanned: 0,
331 new_offset: start_offset,
332 });
333 }
334
335 file.seek(SeekFrom::Start(start_offset))?;
336 let mut reader = std::io::BufReader::new(file);
337 let mut line = Vec::new();
338 let mut events = Vec::new();
339 let mut scanned: u64 = 0;
340 let mut lines_consumed: u64 = 0;
341 let mut new_offset = start_offset;
342 let mut bytes_this_pass: usize = 0;
343
344 loop {
345 if lines_consumed > 0
346 && (bytes_this_pass >= limits.max_bytes_per_pass
347 || events.len() >= limits.max_events_per_pass)
348 {
349 break;
350 }
351
352 line.clear();
353 let line_offset = new_offset;
354
355 match read_line_bounded(&mut reader, &mut line, limits.max_line_bytes)? {
356 LineRead::Eof => break,
357 LineRead::Partial => break, LineRead::OversizedUnterminated { bytes } => {
359 tracing::warn!(
370 path = %path.display(),
371 offset = line_offset,
372 line_bytes = bytes,
373 max_line_bytes = limits.max_line_bytes,
374 "session mirror: oversized JSONL line has no terminator in this bounded read; \
375 leaving cursor at line start for a bounded retry"
376 );
377 break;
378 }
379 LineRead::Oversized { bytes } => {
380 tracing::warn!(
381 path = %path.display(),
382 offset = line_offset,
383 line_bytes = bytes,
384 max_line_bytes = limits.max_line_bytes,
385 "session mirror: skipping oversized JSONL line"
386 );
387 new_offset += bytes as u64;
388 bytes_this_pass += bytes;
389 lines_consumed += 1;
390 }
391 LineRead::Complete { bytes } => {
392 new_offset += bytes as u64;
393 bytes_this_pass += bytes;
394 lines_consumed += 1;
395
396 let raw = String::from_utf8_lossy(&line[..line.len() - 1]);
397 if raw.is_empty() {
398 continue; }
400
401 match source {
402 LineTailSource::ClaudeCode => {
403 if let Some(ev) = parse::parse_cc_line(&raw) {
404 events.push(ev);
405 }
406 }
407 LineTailSource::Codex => {
408 let sid = codex_session_id.unwrap_or("");
409 if let Some(ev) = parse::parse_codex_line(&raw, sid, line_offset) {
410 events.push(ev);
411 }
412 }
413 }
414 scanned += 1;
415 }
416 }
417 }
418
419 Ok(MirrorChunk {
420 events,
421 scanned,
422 new_offset,
423 })
424}
425
426async fn mirror_file_with_limits(
428 runtime: &KhiveRuntime,
429 path: &Path,
430 start_offset: u64,
431 source: LineTailSource,
432 codex_session_id: Option<&str>,
433 limits: MirrorLimits,
434) -> Result<MirrorStats, RuntimeError> {
435 let chunk =
436 read_bounded_chunk(path, start_offset, source, codex_session_id, limits).map_err(|e| {
437 RuntimeError::Internal(format!(
438 "mirror_file: failed to read {:?} at offset {start_offset}: {e}",
439 path
440 ))
441 })?;
442
443 if chunk.new_offset == start_offset {
444 return Ok(MirrorStats {
447 inserted: 0,
448 scanned: 0,
449 new_offset: chunk.new_offset,
450 });
451 }
452
453 if chunk.events.is_empty() {
454 write_cursor_only(runtime, path, &None, chunk.new_offset).await?;
463 return Ok(MirrorStats {
464 inserted: 0,
465 scanned: chunk.scanned,
466 new_offset: chunk.new_offset,
467 });
468 }
469
470 write_events_and_cursor(
471 runtime,
472 path,
473 MirrorSource::from(source).as_str(),
474 &chunk.events,
475 chunk.scanned,
476 chunk.new_offset,
477 )
478 .await
479}
480
481const DEFAULT_CHATGPT_MAX_BYTES: u64 = 256 * 1024 * 1024;
494
495fn chatgpt_max_bytes() -> u64 {
500 std::env::var("KHIVE_MIRROR_CHATGPT_MAX_BYTES")
501 .ok()
502 .and_then(|v| v.parse::<u64>().ok())
503 .filter(|&n| n > 0)
504 .unwrap_or(DEFAULT_CHATGPT_MAX_BYTES)
505}
506
507pub async fn mirror_chatgpt_export_file(
526 runtime: &KhiveRuntime,
527 path: &Path,
528 start_offset: u64,
529) -> Result<MirrorStats, RuntimeError> {
530 mirror_chatgpt_export_file_with_max_bytes(runtime, path, start_offset, chatgpt_max_bytes())
531 .await
532}
533
534async fn mirror_chatgpt_export_file_with_max_bytes(
538 runtime: &KhiveRuntime,
539 path: &Path,
540 start_offset: u64,
541 max_bytes: u64,
542) -> Result<MirrorStats, RuntimeError> {
543 let file_len = std::fs::metadata(path).map(|m| m.len()).map_err(|e| {
544 RuntimeError::Internal(format!(
545 "mirror_chatgpt_export_file: failed to stat {path:?}: {e}"
546 ))
547 })?;
548
549 if file_len <= start_offset {
550 return Ok(MirrorStats {
551 inserted: 0,
552 scanned: 0,
553 new_offset: start_offset,
554 });
555 }
556
557 if file_len > max_bytes {
558 tracing::warn!(
559 path = %path.display(),
560 file_bytes = file_len,
561 max_bytes,
562 "session mirror: skipping oversized ChatGPT export (exceeds KHIVE_MIRROR_CHATGPT_MAX_BYTES)"
563 );
564 return Ok(MirrorStats {
565 inserted: 0,
566 scanned: 0,
567 new_offset: start_offset,
568 });
569 }
570
571 let content = std::fs::read_to_string(path).map_err(|e| {
572 RuntimeError::Internal(format!(
573 "mirror_chatgpt_export_file: failed to read {path:?}: {e}"
574 ))
575 })?;
576
577 let events = parse::parse_chatgpt_export(&content).ok_or_else(|| {
578 RuntimeError::Internal(format!(
579 "mirror_chatgpt_export_file: {path:?} is not a valid ChatGPT export (expected a top-level JSON array)"
580 ))
581 })?;
582
583 let scanned = events.len() as u64;
584
585 write_events_and_cursor(
586 runtime,
587 path,
588 MirrorSource::ChatGptExport.as_str(),
589 &events,
590 scanned,
591 file_len,
592 )
593 .await
594}
595
596async fn write_events_and_cursor(
604 runtime: &KhiveRuntime,
605 path: &Path,
606 source_value: &'static str,
607 events: &[parse::ParsedEvent],
608 scanned: u64,
609 new_offset: u64,
610) -> Result<MirrorStats, RuntimeError> {
611 let now_us = Utc::now().timestamp_micros();
612 let sql = runtime.sql();
613
614 let events_owned: Vec<parse::ParsedEvent> = events.to_vec();
625 let path_owned: PathBuf = path.to_path_buf();
626
627 let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
628 Box::pin(async move {
629 write_events_and_cursor_on_writer(
630 writer,
631 &path_owned,
632 source_value,
633 &events_owned,
634 scanned,
635 new_offset,
636 now_us,
637 )
638 .await
639 .map(|stats| Box::new(stats) as Box<dyn std::any::Any + Send>)
640 .map_err(|e| {
641 khive_storage::StorageError::driver(
642 khive_storage::StorageCapability::Sql,
643 "session_mirror_write_events_and_cursor",
644 e,
645 )
646 })
647 })
648 });
649
650 let boxed = sql
651 .atomic_unit(op)
652 .await
653 .map_err(|e| RuntimeError::Internal(format!("mirror: atomic_unit: {e}")))?;
654
655 Ok(*boxed.downcast::<MirrorStats>().unwrap_or_else(|_| {
656 panic!("atomic_unit op for write_events_and_cursor must return MirrorStats")
657 }))
658}
659
660async fn write_events_and_cursor_on_writer(
667 writer: &mut dyn SqlWriter,
668 path: &Path,
669 source_value: &'static str,
670 events: &[parse::ParsedEvent],
671 scanned: u64,
672 new_offset: u64,
673 now_us: i64,
674) -> khive_storage::types::StorageResult<MirrorStats> {
675 let mut inserted: u64 = 0;
676 let mut last_session_id: Option<String> = None;
677
678 for ev in events {
679 let created_at = if ev.created_at_micros != 0 {
680 ev.created_at_micros
681 } else {
682 now_us
683 };
684
685 writer
693 .execute(SqlStatement {
694 sql: format!(
695 "INSERT INTO sessions \
696 (id, provider_session_id, source, cwd, git_branch, slug, \
697 message_count, first_seen_at, last_seen_at, namespace) \
698 VALUES(?1, ?1, '{}', ?2, ?3, ?4, 0, ?5, ?5, 'local') \
699 ON CONFLICT(id) DO NOTHING",
700 source_value
701 ),
702 params: vec![
703 SqlValue::Text(ev.session_id.clone()),
704 ev.cwd
705 .as_deref()
706 .map(|s| SqlValue::Text(s.to_string()))
707 .unwrap_or(SqlValue::Null),
708 ev.git_branch
709 .as_deref()
710 .map(|s| SqlValue::Text(s.to_string()))
711 .unwrap_or(SqlValue::Null),
712 ev.slug
713 .as_deref()
714 .map(|s| SqlValue::Text(s.to_string()))
715 .unwrap_or(SqlValue::Null),
716 SqlValue::Integer(created_at),
717 ],
718 label: Some("session_mirror_create_session".into()),
719 })
720 .await
721 .map_err(|e| {
722 khive_storage::StorageError::driver(
723 khive_storage::StorageCapability::Sql,
724 "mirror: session create",
725 e,
726 )
727 })?;
728
729 let affected = writer
731 .execute(SqlStatement {
732 sql: "INSERT OR IGNORE INTO session_messages \
733 (id, session_id, seq, parent_uuid, is_sidechain, role, \
734 msg_type, text, raw, created_at, namespace) \
735 VALUES(?1, ?2, \
736 (SELECT COALESCE(MAX(seq),-1)+1 FROM session_messages WHERE session_id=?2), \
737 ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'local')"
738 .into(),
739 params: vec![
740 SqlValue::Text(ev.uuid.clone()),
741 SqlValue::Text(ev.session_id.clone()),
742 ev.parent_uuid
743 .as_deref()
744 .map(|s| SqlValue::Text(s.to_string()))
745 .unwrap_or(SqlValue::Null),
746 SqlValue::Integer(i64::from(ev.is_sidechain)),
747 ev.role
748 .as_deref()
749 .map(|s| SqlValue::Text(s.to_string()))
750 .unwrap_or(SqlValue::Null),
751 SqlValue::Text(ev.msg_type.clone()),
752 ev.text
753 .as_deref()
754 .map(|s| SqlValue::Text(s.to_string()))
755 .unwrap_or(SqlValue::Null),
756 SqlValue::Text(ev.raw.clone()),
757 SqlValue::Integer(created_at),
758 ],
759 label: Some("session_mirror_insert_message".into()),
760 })
761 .await
762 .map_err(|e| {
763 khive_storage::StorageError::driver(
764 khive_storage::StorageCapability::Sql,
765 "mirror: message insert",
766 e,
767 )
768 })?;
769
770 if affected > 0 {
777 writer
778 .execute(SqlStatement {
779 sql: "UPDATE sessions SET \
780 last_seen_at=MAX(last_seen_at, ?2), \
781 cwd=COALESCE(cwd, ?3), \
782 git_branch=COALESCE(git_branch, ?4), \
783 slug=COALESCE(slug, ?5) \
784 WHERE id=?1"
785 .into(),
786 params: vec![
787 SqlValue::Text(ev.session_id.clone()),
788 SqlValue::Integer(created_at),
789 ev.cwd
790 .as_deref()
791 .map(|s| SqlValue::Text(s.to_string()))
792 .unwrap_or(SqlValue::Null),
793 ev.git_branch
794 .as_deref()
795 .map(|s| SqlValue::Text(s.to_string()))
796 .unwrap_or(SqlValue::Null),
797 ev.slug
798 .as_deref()
799 .map(|s| SqlValue::Text(s.to_string()))
800 .unwrap_or(SqlValue::Null),
801 ],
802 label: Some("session_mirror_touch_session".into()),
803 })
804 .await
805 .map_err(|e| {
806 khive_storage::StorageError::driver(
807 khive_storage::StorageCapability::Sql,
808 "mirror: session touch",
809 e,
810 )
811 })?;
812 }
813
814 inserted += affected;
815 last_session_id = Some(ev.session_id.clone());
816 }
817
818 if inserted > 0 {
825 let mut seen_sessions: Vec<String> = events
826 .iter()
827 .map(|e| e.session_id.clone())
828 .collect::<std::collections::HashSet<_>>()
829 .into_iter()
830 .collect();
831 seen_sessions.sort(); for sid in &seen_sessions {
834 writer
835 .execute(SqlStatement {
836 sql: "UPDATE sessions SET message_count=\
837 (SELECT COUNT(*) FROM session_messages WHERE session_id=?1) \
838 WHERE id=?1"
839 .into(),
840 params: vec![SqlValue::Text(sid.clone())],
841 label: Some("session_mirror_refresh_count".into()),
842 })
843 .await
844 .map_err(|e| {
845 khive_storage::StorageError::driver(
846 khive_storage::StorageCapability::Sql,
847 "mirror: count refresh",
848 e,
849 )
850 })?;
851 }
852 }
853
854 upsert_cursor_on_writer(writer, path, last_session_id.as_deref(), new_offset, now_us).await?;
855
856 Ok(MirrorStats {
864 inserted,
865 scanned,
866 new_offset,
867 })
868}
869
870async fn upsert_cursor_on_writer(
875 writer: &mut dyn SqlWriter,
876 path: &Path,
877 session_id: Option<&str>,
878 new_offset: u64,
879 now_us: i64,
880) -> khive_storage::types::StorageResult<()> {
881 let path_str = path.to_string_lossy().into_owned();
882 writer
883 .execute(SqlStatement {
884 sql:
885 "INSERT INTO session_mirror_cursor(file_path, session_id, byte_offset, updated_at) \
886 VALUES(?1, ?2, ?3, ?4) \
887 ON CONFLICT(file_path) DO UPDATE SET \
888 session_id=excluded.session_id, \
889 byte_offset=excluded.byte_offset, \
890 updated_at=excluded.updated_at"
891 .into(),
892 params: vec![
893 SqlValue::Text(path_str),
894 session_id
895 .map(|s| SqlValue::Text(s.to_string()))
896 .unwrap_or(SqlValue::Null),
897 SqlValue::Integer(new_offset as i64),
898 SqlValue::Integer(now_us),
899 ],
900 label: Some("session_mirror_cursor_upsert".into()),
901 })
902 .await
903 .map_err(|e| {
904 khive_storage::StorageError::driver(
905 khive_storage::StorageCapability::Sql,
906 "mirror: cursor upsert",
907 e,
908 )
909 })?;
910 Ok(())
911}
912
913async fn write_cursor_only(
916 runtime: &KhiveRuntime,
917 path: &Path,
918 session_id: &Option<String>,
919 new_offset: u64,
920) -> Result<(), RuntimeError> {
921 let now_us = Utc::now().timestamp_micros();
922 let path_str = path.to_string_lossy().into_owned();
923 let sql = runtime.sql();
924 let mut w = sql
925 .writer()
926 .await
927 .map_err(|e| RuntimeError::Internal(format!("mirror_file: cursor writer: {e}")))?;
928 w.execute(SqlStatement {
929 sql: "INSERT INTO session_mirror_cursor(file_path, session_id, byte_offset, updated_at) \
930 VALUES(?1, ?2, ?3, ?4) \
931 ON CONFLICT(file_path) DO UPDATE SET \
932 session_id=COALESCE(excluded.session_id, session_mirror_cursor.session_id), \
933 byte_offset=excluded.byte_offset, \
934 updated_at=excluded.updated_at"
935 .into(),
936 params: vec![
937 SqlValue::Text(path_str),
938 session_id
939 .as_deref()
940 .map(|s| SqlValue::Text(s.to_string()))
941 .unwrap_or(SqlValue::Null),
942 SqlValue::Integer(new_offset as i64),
943 SqlValue::Integer(now_us),
944 ],
945 label: Some("session_mirror_cursor_only".into()),
946 })
947 .await
948 .map_err(|e| RuntimeError::Internal(format!("mirror_file: cursor write: {e}")))?;
949 Ok(())
950}
951
952#[cfg(test)]
955mod tests {
956 use std::io::Write;
957 use std::sync::Arc;
958
959 use khive_runtime::{
960 AllowAllGate, BackendId, KhiveRuntime, Namespace, RuntimeConfig, RuntimeError,
961 };
962 use khive_storage::types::{SqlStatement, SqlValue};
963 use tempfile::{NamedTempFile, TempDir};
964
965 use super::*;
966 use crate::vocab::SESSION_SCHEMA_PLAN_STMTS;
967
968 async fn setup() -> (KhiveRuntime, TempDir) {
976 let dir = TempDir::new().expect("tempdir");
977 let db_path = dir.path().join("test.db");
978 let rt = KhiveRuntime::new(RuntimeConfig {
979 db_path: Some(db_path),
980 default_namespace: Namespace::local(),
981 embedding_model: None,
982 additional_embedding_models: vec![],
983 gate: Arc::new(AllowAllGate),
984 packs: vec!["kg".to_string()],
985 backend_id: BackendId::main(),
986 brain_profile: None,
987 visible_namespaces: vec![],
988 allowed_outbound_namespaces: vec![],
989 actor_id: None,
990 })
991 .expect("file-backed runtime");
992 apply_session_schema(&rt).await;
993 (rt, dir)
994 }
995
996 async fn apply_session_schema(rt: &KhiveRuntime) {
997 let sql = rt.sql();
998 let mut w = sql.writer().await.expect("writer");
999 for stmt in &SESSION_SCHEMA_PLAN_STMTS {
1000 w.execute_script(stmt.to_string())
1001 .await
1002 .expect("schema stmt");
1003 }
1004 }
1006
1007 async fn count_rows(rt: &KhiveRuntime, table: &str) -> i64 {
1009 let sql = rt.sql();
1010 let mut r = sql.reader().await.expect("reader");
1011 let row = r
1012 .query_row(SqlStatement {
1013 sql: format!("SELECT COUNT(*) FROM {table}"),
1014 params: vec![],
1015 label: None,
1016 })
1017 .await
1018 .expect("count query")
1019 .expect("count row");
1020 match row.columns.first().map(|c| &c.value) {
1021 Some(SqlValue::Integer(n)) => *n,
1022 _ => 0,
1023 }
1024 }
1025
1026 async fn cursor_offset(rt: &KhiveRuntime, path_str: &str) -> Option<i64> {
1028 let sql = rt.sql();
1029 let mut r = sql.reader().await.expect("reader");
1030 let row = r
1031 .query_row(SqlStatement {
1032 sql: "SELECT byte_offset FROM session_mirror_cursor WHERE file_path=?1".into(),
1033 params: vec![SqlValue::Text(path_str.to_string())],
1034 label: None,
1035 })
1036 .await
1037 .expect("cursor query")?;
1038 match row.columns.first().map(|c| &c.value) {
1039 Some(SqlValue::Integer(n)) => Some(*n),
1040 _ => None,
1041 }
1042 }
1043
1044 fn user_line(uuid: &str, session_id: &str, text: &str) -> String {
1045 format!(
1046 r#"{{"uuid":"{uuid}","sessionId":"{session_id}","type":"user","timestamp":"2026-06-29T10:00:00Z","message":{{"role":"user","content":"{text}"}}}}"#
1047 )
1048 }
1049
1050 fn user_line_no_ts(uuid: &str, session_id: &str, text: &str) -> String {
1052 format!(
1053 r#"{{"uuid":"{uuid}","sessionId":"{session_id}","type":"user","message":{{"role":"user","content":"{text}"}}}}"#
1054 )
1055 }
1056
1057 async fn last_seen_at(rt: &KhiveRuntime, session_id: &str) -> Option<i64> {
1059 let sql = rt.sql();
1060 let mut r = sql.reader().await.expect("reader");
1061 let row = r
1062 .query_row(SqlStatement {
1063 sql: "SELECT last_seen_at FROM sessions WHERE id=?1".into(),
1064 params: vec![SqlValue::Text(session_id.to_string())],
1065 label: None,
1066 })
1067 .await
1068 .expect("last_seen query")?;
1069 match row.columns.first().map(|c| &c.value) {
1070 Some(SqlValue::Integer(n)) => Some(*n),
1071 _ => None,
1072 }
1073 }
1074
1075 #[tokio::test]
1076 async fn test_mirror_three_lines_and_idempotency() {
1077 let (rt, _dir) = setup().await;
1078
1079 let line1 = user_line("uuid-1", "sess-A", "Hello");
1081 let line2 = user_line("uuid-2", "sess-A", "World");
1082 let line3 = user_line("uuid-3", "sess-A", "Done");
1083
1084 let mut file = NamedTempFile::new().expect("tmpfile");
1085 writeln!(file, "{line1}").unwrap();
1086 writeln!(file, "{line2}").unwrap();
1087 writeln!(file, "{line3}").unwrap();
1088
1089 let path = file.path().to_path_buf();
1090
1091 let stats = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1093 .await
1094 .expect("mirror_file first call");
1095 assert_eq!(stats.inserted, 3, "should insert 3 new messages");
1096 assert_eq!(stats.scanned, 3, "should scan 3 lines");
1097 assert!(stats.new_offset > 0, "offset should advance");
1098
1099 let msg_count = count_rows(&rt, "session_messages").await;
1100 assert_eq!(msg_count, 3, "3 messages in DB");
1101
1102 let session_count = count_rows(&rt, "sessions").await;
1103 assert_eq!(session_count, 1, "1 session row");
1104
1105 let stats2 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1107 .await
1108 .expect("mirror_file second call");
1109 assert_eq!(stats2.inserted, 0, "second pass must insert 0 rows");
1110 assert_eq!(count_rows(&rt, "session_messages").await, 3);
1111
1112 let stats3 = mirror_file(
1114 &rt,
1115 &path,
1116 stats.new_offset,
1117 LineTailSource::ClaudeCode,
1118 None,
1119 )
1120 .await
1121 .expect("mirror_file from new_offset");
1122 assert_eq!(stats3.inserted, 0, "no new data past advanced offset");
1123 assert_eq!(stats3.new_offset, stats.new_offset);
1124
1125 let stored_offset = cursor_offset(&rt, &path.to_string_lossy()).await;
1127 assert!(stored_offset.is_some(), "cursor should be recorded");
1128 assert_eq!(stored_offset.unwrap(), stats.new_offset as i64);
1129 }
1130
1131 #[tokio::test]
1132 async fn mirror_file_respects_low_test_cap_and_advances_over_multiple_passes() {
1133 let (rt, _dir) = setup().await;
1141
1142 let lines: Vec<String> = (0..6)
1143 .map(|i| user_line(&format!("uuid-cap-{i}"), "sess-CAP", &format!("line{i}")))
1144 .collect();
1145
1146 let mut file = NamedTempFile::new().expect("tmpfile");
1147 for line in &lines {
1148 writeln!(file, "{line}").unwrap();
1149 }
1150 let path = file.path().to_path_buf();
1151 let file_len = std::fs::metadata(&path).unwrap().len();
1152
1153 let cap_bytes = (lines[0].len() + 1) + (lines[1].len() + 1);
1157 let limits = MirrorLimits {
1158 max_bytes_per_pass: cap_bytes,
1159 max_events_per_pass: 1024,
1160 max_line_bytes: MIRROR_MAX_LINE_BYTES,
1161 };
1162
1163 let stats1 =
1164 mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1165 .await
1166 .expect("first bounded pass");
1167 assert_eq!(
1168 stats1.inserted, 2,
1169 "first pass must stop at the byte cap, not read the whole file"
1170 );
1171 assert_eq!(stats1.scanned, 2);
1172 assert!(
1173 stats1.new_offset < file_len,
1174 "new_offset {new} must be less than file_len {file_len} for a bounded pass",
1175 new = stats1.new_offset
1176 );
1177 assert_eq!(
1178 cursor_offset(&rt, &path.to_string_lossy()).await,
1179 Some(stats1.new_offset as i64),
1180 "cursor must be committed after the first bounded pass"
1181 );
1182
1183 let stats2 = mirror_file_with_limits(
1184 &rt,
1185 &path,
1186 stats1.new_offset,
1187 LineTailSource::ClaudeCode,
1188 None,
1189 limits,
1190 )
1191 .await
1192 .expect("second bounded pass");
1193 assert_eq!(stats2.inserted, 2);
1194 assert!(stats2.new_offset > stats1.new_offset);
1195 assert!(stats2.new_offset < file_len);
1196
1197 let stats3 = mirror_file_with_limits(
1198 &rt,
1199 &path,
1200 stats2.new_offset,
1201 LineTailSource::ClaudeCode,
1202 None,
1203 limits,
1204 )
1205 .await
1206 .expect("third bounded pass");
1207 assert_eq!(stats3.inserted, 2);
1208 assert_eq!(stats3.new_offset, file_len, "final pass must reach EOF");
1209
1210 assert_eq!(count_rows(&rt, "session_messages").await, 6);
1214 assert_eq!(
1215 cursor_offset(&rt, &path.to_string_lossy()).await,
1216 Some(file_len as i64)
1217 );
1218
1219 let stats4 = mirror_file_with_limits(
1221 &rt,
1222 &path,
1223 stats3.new_offset,
1224 LineTailSource::ClaudeCode,
1225 None,
1226 limits,
1227 )
1228 .await
1229 .expect("fourth pass at EOF");
1230 assert_eq!(stats4.inserted, 0);
1231 assert_eq!(stats4.scanned, 0);
1232 }
1233
1234 #[tokio::test]
1235 async fn test_oversized_line_is_skipped_and_offset_advances() {
1236 let (rt, _dir) = setup().await;
1242
1243 let small1 = user_line("uuid-small1", "sess-OV", "ok");
1244 let huge_text = "x".repeat(2000);
1245 let huge = user_line("uuid-huge", "sess-OV", &huge_text);
1246 let small2 = user_line("uuid-small2", "sess-OV", "after");
1247
1248 let mut file = NamedTempFile::new().expect("tmpfile");
1249 writeln!(file, "{small1}").unwrap();
1250 writeln!(file, "{huge}").unwrap();
1251 writeln!(file, "{small2}").unwrap();
1252 let path = file.path().to_path_buf();
1253 let file_len = std::fs::metadata(&path).unwrap().len();
1254
1255 let max_line_bytes: usize = 256;
1256 assert!(
1257 huge.len() + 1 > max_line_bytes,
1258 "fixture huge line must exceed the cap"
1259 );
1260 assert!(
1261 small1.len() + 1 < max_line_bytes && small2.len() + 1 < max_line_bytes,
1262 "fixture small lines must fit under the cap"
1263 );
1264
1265 let limits = MirrorLimits {
1266 max_bytes_per_pass: MIRROR_MAX_BYTES_PER_PASS,
1267 max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
1268 max_line_bytes,
1269 };
1270
1271 let stats =
1272 mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1273 .await
1274 .expect("mirror with a small line cap");
1275
1276 assert_eq!(stats.inserted, 2, "only the two small lines are inserted");
1277 assert_eq!(
1278 stats.scanned, 2,
1279 "the oversized line must not count toward scanned"
1280 );
1281 assert_eq!(
1282 stats.new_offset, file_len,
1283 "offset must advance past the oversized line, not wedge on it"
1284 );
1285 assert_eq!(count_rows(&rt, "session_messages").await, 2);
1286 }
1287
1288 #[tokio::test]
1289 async fn test_line_just_under_cap_then_oversized_next_line_is_bounded() {
1290 let (rt, _dir) = setup().await;
1297
1298 let max_line_bytes: usize = 256;
1299 let shell_len = user_line("uuid-a", "sess-BND", "").len() + 1; let pad = max_line_bytes.saturating_sub(shell_len).saturating_sub(4);
1301 let text_a = "y".repeat(pad);
1302 let line_a = user_line("uuid-a", "sess-BND", &text_a);
1303
1304 let huge_text = "z".repeat(max_line_bytes * 4);
1305 let line_b = user_line("uuid-b", "sess-BND", &huge_text);
1306
1307 let mut file = NamedTempFile::new().expect("tmpfile");
1308 writeln!(file, "{line_a}").unwrap();
1309 writeln!(file, "{line_b}").unwrap();
1310 let path = file.path().to_path_buf();
1311 let file_len = std::fs::metadata(&path).unwrap().len();
1312
1313 assert!(
1314 line_a.len() + 1 < max_line_bytes,
1315 "fixture line A must land just under the cap"
1316 );
1317 assert!(
1318 line_b.len() + 1 > max_line_bytes,
1319 "fixture line B must land over the cap"
1320 );
1321
1322 let limits = MirrorLimits {
1323 max_bytes_per_pass: MIRROR_MAX_BYTES_PER_PASS,
1324 max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
1325 max_line_bytes,
1326 };
1327
1328 let stats =
1329 mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1330 .await
1331 .expect("mirror with a boundary line cap");
1332
1333 assert_eq!(stats.inserted, 1, "only the under-cap line is inserted");
1334 assert_eq!(
1335 stats.scanned, 1,
1336 "the oversized line must not count toward scanned"
1337 );
1338 assert_eq!(
1339 stats.new_offset, file_len,
1340 "offset must advance past both lines, including the skipped oversized one"
1341 );
1342 assert_eq!(count_rows(&rt, "session_messages").await, 1);
1343 }
1344
1345 struct CountingReader<R> {
1350 inner: R,
1351 total_read: std::rc::Rc<std::cell::Cell<usize>>,
1352 }
1353
1354 impl<R: std::io::Read> std::io::Read for CountingReader<R> {
1355 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1356 let n = self.inner.read(buf)?;
1357 self.total_read.set(self.total_read.get() + n);
1358 Ok(n)
1359 }
1360 }
1361
1362 #[test]
1363 fn test_read_line_bounded_oversized_unterminated_reads_are_capped_per_call() {
1364 let max_line_bytes: usize = 64;
1372 let buf_capacity: usize = 256;
1373 let data = vec![b'x'; 200_000];
1376
1377 let total_read = std::rc::Rc::new(std::cell::Cell::new(0));
1378 let counting = CountingReader {
1379 inner: std::io::Cursor::new(data),
1380 total_read: total_read.clone(),
1381 };
1382 let mut reader = std::io::BufReader::with_capacity(buf_capacity, counting);
1383 let mut buf = Vec::new();
1384
1385 let outcome =
1386 read_line_bounded(&mut reader, &mut buf, max_line_bytes).expect("read must not error");
1387
1388 match outcome {
1389 LineRead::OversizedUnterminated { bytes } => {
1390 assert!(
1391 bytes > max_line_bytes,
1392 "must have detected the crossing of the cap, got {bytes}"
1393 );
1394 }
1395 other => panic!("expected OversizedUnterminated, got {other:?}"),
1396 }
1397 assert!(
1398 buf.is_empty(),
1399 "buf must never buffer anything once the line is flagged oversized"
1400 );
1401
1402 let read_bytes = total_read.get();
1407 assert!(
1408 read_bytes <= max_line_bytes + buf_capacity * 4,
1409 "read_line_bounded pulled {read_bytes} bytes from the source for an \
1410 unterminated oversized line — expected at most {} (bounded to the \
1411 cap plus a few buffer refills), not an unbounded scan toward EOF",
1412 max_line_bytes + buf_capacity * 4
1413 );
1414 }
1415
1416 #[tokio::test]
1417 async fn test_oversized_unterminated_line_leaves_cursor_at_line_start_and_is_bounded_on_retry()
1418 {
1419 let (rt, _dir) = setup().await;
1425
1426 let max_line_bytes: usize = 256;
1427 let huge_unterminated = "z".repeat(max_line_bytes * 20);
1429
1430 let mut file = NamedTempFile::new().expect("tmpfile");
1431 file.write_all(huge_unterminated.as_bytes())
1432 .expect("write unterminated line");
1433 let path = file.path().to_path_buf();
1434
1435 let limits = MirrorLimits {
1436 max_bytes_per_pass: MIRROR_MAX_BYTES_PER_PASS,
1437 max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
1438 max_line_bytes,
1439 };
1440
1441 let stats1 =
1444 mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1445 .await
1446 .expect("first pass over an unterminated oversized line");
1447 assert_eq!(
1448 stats1.new_offset, 0,
1449 "cursor must stay at the line start — nothing was durably consumed"
1450 );
1451 assert_eq!(stats1.scanned, 0);
1452 assert_eq!(stats1.inserted, 0);
1453 assert_eq!(
1454 count_rows(&rt, "session_messages").await,
1455 0,
1456 "no partial/garbage row may be written for an unterminated oversized line"
1457 );
1458
1459 let stats2 = mirror_file_with_limits(
1464 &rt,
1465 &path,
1466 stats1.new_offset,
1467 LineTailSource::ClaudeCode,
1468 None,
1469 limits,
1470 )
1471 .await
1472 .expect("second pass (simulated daemon restart) over the same unterminated line");
1473 assert_eq!(stats2.new_offset, 0);
1474 assert_eq!(stats2.scanned, 0);
1475 assert_eq!(stats2.inserted, 0);
1476 assert_eq!(count_rows(&rt, "session_messages").await, 0);
1477
1478 let small_after = user_line("uuid-after-huge", "sess-UNTERM", "after");
1483 {
1484 let mut f = std::fs::OpenOptions::new()
1485 .append(true)
1486 .open(&path)
1487 .expect("reopen for append");
1488 writeln!(f).unwrap(); writeln!(f, "{small_after}").unwrap();
1490 }
1491 let file_len = std::fs::metadata(&path).unwrap().len();
1492
1493 let stats3 = mirror_file_with_limits(
1494 &rt,
1495 &path,
1496 stats2.new_offset,
1497 LineTailSource::ClaudeCode,
1498 None,
1499 limits,
1500 )
1501 .await
1502 .expect("third pass once the huge line terminates");
1503 assert_eq!(
1504 stats3.new_offset, file_len,
1505 "once terminated, the skip-and-advance path must clear past the whole \
1506 oversized line plus the following valid line"
1507 );
1508 assert_eq!(stats3.scanned, 1, "only the small trailing line is scanned");
1509 assert_eq!(stats3.inserted, 1);
1510 assert_eq!(count_rows(&rt, "session_messages").await, 1);
1511 }
1512
1513 #[tokio::test]
1514 async fn test_still_growing_partial_line_under_cap_is_unaffected() {
1515 let (rt, _dir) = setup().await;
1521
1522 let small1 = user_line("uuid-g1", "sess-GROW", "first");
1523 let mut file = NamedTempFile::new().expect("tmpfile");
1524 writeln!(file, "{small1}").unwrap();
1525 let partial_prefix = user_line("uuid-g2", "sess-GROW", "second");
1527 file.write_all(partial_prefix.as_bytes())
1528 .expect("write partial line, no trailing newline");
1529 let path = file.path().to_path_buf();
1530
1531 let limits = MirrorLimits::production();
1532
1533 let stats1 =
1534 mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1535 .await
1536 .expect("first pass: complete line + partial trailing line");
1537 assert_eq!(stats1.scanned, 1, "only the complete first line is scanned");
1538 assert_eq!(stats1.inserted, 1);
1539 assert_eq!(
1540 stats1.new_offset,
1541 (small1.len() + 1) as u64,
1542 "cursor must stop right after the first complete line, not consume the partial tail"
1543 );
1544
1545 writeln!(file).unwrap();
1547 let file_len = std::fs::metadata(&path).unwrap().len();
1548
1549 let stats2 = mirror_file_with_limits(
1550 &rt,
1551 &path,
1552 stats1.new_offset,
1553 LineTailSource::ClaudeCode,
1554 None,
1555 limits,
1556 )
1557 .await
1558 .expect("second pass: the previously-partial line now completes");
1559 assert_eq!(stats2.new_offset, file_len);
1560 assert_eq!(stats2.scanned, 1);
1561 assert_eq!(stats2.inserted, 1);
1562 assert_eq!(count_rows(&rt, "session_messages").await, 2);
1563 }
1564
1565 #[tokio::test]
1566 async fn test_large_run_of_blank_lines_is_bounded_and_persists_cursor() {
1567 let (rt, _dir) = setup().await;
1574
1575 let mut file = NamedTempFile::new().expect("tmpfile");
1576 for _ in 0..500 {
1577 writeln!(file).unwrap(); }
1579 let path = file.path().to_path_buf();
1580 let file_len = std::fs::metadata(&path).unwrap().len();
1581 assert_eq!(file_len, 500, "500 one-byte blank lines");
1582
1583 let limits = MirrorLimits {
1586 max_bytes_per_pass: 50,
1587 max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
1588 max_line_bytes: MIRROR_MAX_LINE_BYTES,
1589 };
1590
1591 let stats1 =
1592 mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1593 .await
1594 .expect("first blank-line pass");
1595
1596 assert_eq!(stats1.inserted, 0);
1597 assert_eq!(stats1.scanned, 0, "blank lines never count toward scanned");
1598 assert!(
1599 stats1.new_offset > 0,
1600 "the pass cap must trip after at least one blank line, not read unbounded"
1601 );
1602 assert!(
1603 stats1.new_offset < file_len,
1604 "a bounded pass over an all-blank file must not reach EOF in one call"
1605 );
1606
1607 let stored_offset = cursor_offset(&rt, &path.to_string_lossy()).await;
1609 assert_eq!(
1610 stored_offset,
1611 Some(stats1.new_offset as i64),
1612 "cursor must be persisted even when the pass scanned zero events"
1613 );
1614
1615 let mut offset = stats1.new_offset;
1618 loop {
1619 let stats = mirror_file_with_limits(
1620 &rt,
1621 &path,
1622 offset,
1623 LineTailSource::ClaudeCode,
1624 None,
1625 limits,
1626 )
1627 .await
1628 .expect("subsequent blank-line pass");
1629 assert_eq!(stats.inserted, 0);
1630 if stats.new_offset == offset {
1631 break; }
1633 offset = stats.new_offset;
1634 }
1635 assert_eq!(
1636 offset, file_len,
1637 "all blank lines eventually consumed to EOF"
1638 );
1639 }
1640
1641 #[tokio::test]
1642 async fn test_partial_trailing_line_not_consumed() {
1643 let (rt, _dir) = setup().await;
1644
1645 let line1 = user_line("uuid-p1", "sess-B", "Complete");
1646 let partial = r#"{"uuid":"uuid-p2","sessionId":"sess-B","type":"user""#;
1648
1649 let mut file = NamedTempFile::new().expect("tmpfile");
1650 writeln!(file, "{line1}").unwrap(); write!(file, "{partial}").unwrap(); let path = file.path().to_path_buf();
1654 let full_len = std::fs::metadata(&path).unwrap().len();
1655
1656 let stats = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1657 .await
1658 .expect("mirror_file partial");
1659
1660 assert_eq!(stats.inserted, 1, "only 1 complete line inserted");
1662 assert!(
1663 stats.new_offset < full_len,
1664 "new_offset {new} must be less than file_len {full_len}",
1665 new = stats.new_offset
1666 );
1667
1668 let stats2 = mirror_file(
1670 &rt,
1671 &path,
1672 stats.new_offset,
1673 LineTailSource::ClaudeCode,
1674 None,
1675 )
1676 .await
1677 .expect("second call");
1678 assert_eq!(
1679 stats2.inserted, 0,
1680 "partial line must not be consumed on re-poll"
1681 );
1682 assert_eq!(
1683 stats2.new_offset, stats.new_offset,
1684 "offset must not advance on partial-only content"
1685 );
1686 }
1687
1688 #[tokio::test]
1689 async fn test_duplicate_uuid_across_two_calls() {
1690 let (rt, _dir) = setup().await;
1691
1692 let line = user_line("uuid-dup", "sess-C", "First");
1693
1694 let mut file = NamedTempFile::new().expect("tmpfile");
1695 writeln!(file, "{line}").unwrap();
1696
1697 let path = file.path().to_path_buf();
1698
1699 let s1 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1701 .await
1702 .unwrap();
1703 assert_eq!(s1.inserted, 1);
1704
1705 writeln!(file, "{line}").unwrap();
1707
1708 let s2 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1710 .await
1711 .unwrap();
1712 assert_eq!(s2.inserted, 0, "duplicate uuid must not be re-inserted");
1713 assert_eq!(count_rows(&rt, "session_messages").await, 1);
1714
1715 let s3 = mirror_file(&rt, &path, s1.new_offset, LineTailSource::ClaudeCode, None)
1717 .await
1718 .unwrap();
1719 assert_eq!(s3.inserted, 0, "incremental dup must also insert 0");
1720 }
1721
1722 #[tokio::test]
1723 async fn test_replay_does_not_mutate_session_metadata() {
1724 let (rt, _dir) = setup().await;
1729
1730 let line = user_line_no_ts("uuid-nts", "sess-NTS", "no timestamp here");
1731 let mut file = NamedTempFile::new().expect("tmpfile");
1732 writeln!(file, "{line}").unwrap();
1733 let path = file.path().to_path_buf();
1734
1735 let s1 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1736 .await
1737 .unwrap();
1738 assert_eq!(s1.inserted, 1);
1739 let seen_after_first = last_seen_at(&rt, "sess-NTS")
1740 .await
1741 .expect("session row exists");
1742
1743 let s2 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1746 .await
1747 .unwrap();
1748 assert_eq!(s2.inserted, 0, "replay must insert 0 rows");
1749 let seen_after_replay = last_seen_at(&rt, "sess-NTS").await.unwrap();
1750 assert_eq!(
1751 seen_after_first, seen_after_replay,
1752 "replay must not advance last_seen_at for a timestamp-missing event"
1753 );
1754 }
1755
1756 #[tokio::test]
1757 async fn test_empty_file_is_a_no_op() {
1758 let (rt, _dir) = setup().await;
1759
1760 let file = NamedTempFile::new().expect("tmpfile");
1761 let path = file.path().to_path_buf();
1762
1763 let stats = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1764 .await
1765 .unwrap();
1766 assert_eq!(stats.inserted, 0);
1767 assert_eq!(stats.scanned, 0);
1768 assert_eq!(stats.new_offset, 0);
1769 }
1770
1771 #[tokio::test]
1772 async fn test_missing_file_returns_error() {
1773 let (rt, _dir) = setup().await;
1774 let bad_path = std::path::PathBuf::from("/nonexistent/path/session.jsonl");
1775 let result = mirror_file(&rt, &bad_path, 0, LineTailSource::ClaudeCode, None).await;
1776 assert!(
1777 matches!(result, Err(RuntimeError::Internal(_))),
1778 "missing file should return Internal error"
1779 );
1780 }
1781
1782 fn codex_message_line(role: &str, text: &str) -> String {
1788 let block_type = if role == "assistant" {
1789 "output_text"
1790 } else {
1791 "input_text"
1792 };
1793 format!(
1794 r#"{{"type":"response_item","timestamp":"2026-06-30T08:00:00Z","payload":{{"type":"message","role":"{role}","content":[{{"type":"{block_type}","text":"{text}"}}]}}}}"#
1795 )
1796 }
1797
1798 fn codex_meta_line(session_id: &str, cwd: &str, branch: &str) -> String {
1800 format!(
1801 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"}}}}}}"#
1802 )
1803 }
1804
1805 fn codex_event_msg_line() -> String {
1807 r#"{"type":"event_msg","timestamp":"2026-06-30T08:00:00Z","payload":{"type":"user_message","content":"should be skipped"}}"#.to_string()
1808 }
1809
1810 #[tokio::test]
1811 async fn test_codex_mirror_inserts_with_source_codex() {
1812 let (rt, _dir) = setup().await;
1813
1814 let session_id = "cdx-sess-0001-0001-0001-000000000001";
1815 let meta = codex_meta_line(session_id, "/home/lion/proj", "feat-x");
1816 let user_msg = codex_message_line("user", "Hello from Codex");
1817 let asst_msg = codex_message_line("assistant", "Hello back from Codex");
1818 let skip_msg = codex_event_msg_line();
1819
1820 let mut file = NamedTempFile::new().expect("tmpfile");
1821 writeln!(file, "{meta}").unwrap();
1822 writeln!(file, "{user_msg}").unwrap();
1823 writeln!(file, "{asst_msg}").unwrap();
1824 writeln!(file, "{skip_msg}").unwrap();
1825
1826 let path = file.path().to_path_buf();
1827
1828 let stats = mirror_file(&rt, &path, 0, LineTailSource::Codex, Some(session_id))
1830 .await
1831 .expect("codex mirror_file");
1832
1833 assert_eq!(stats.inserted, 3, "meta + 2 messages inserted");
1835 assert_eq!(
1836 stats.scanned, 4,
1837 "4 lines total (including skipped event_msg)"
1838 );
1839 assert!(stats.new_offset > 0);
1840
1841 let sql = rt.sql();
1843 let mut r = sql.reader().await.expect("reader");
1844 let session_row = r
1845 .query_row(SqlStatement {
1846 sql: "SELECT source FROM sessions WHERE id=?1".into(),
1847 params: vec![SqlValue::Text(session_id.to_string())],
1848 label: None,
1849 })
1850 .await
1851 .expect("query ok")
1852 .expect("session row must exist");
1853 match session_row.columns.first().map(|c| &c.value) {
1854 Some(SqlValue::Text(s)) => assert_eq!(s, "codex", "source must be 'codex'"),
1855 other => panic!("unexpected source value: {other:?}"),
1856 }
1857
1858 assert_eq!(count_rows(&rt, "session_messages").await, 3);
1860
1861 let mut r2 = sql.reader().await.expect("reader");
1865 let rows = r2
1866 .query_all(SqlStatement {
1867 sql: "SELECT role, text FROM session_messages \
1868 WHERE session_id=?1 AND role IS NOT NULL ORDER BY seq"
1869 .into(),
1870 params: vec![SqlValue::Text(session_id.to_string())],
1871 label: None,
1872 })
1873 .await
1874 .expect("query ok");
1875 let texts: Vec<(String, String)> = rows
1876 .iter()
1877 .map(|row| {
1878 let role = match row.get("role") {
1879 Some(SqlValue::Text(s)) => s.clone(),
1880 other => panic!("unexpected role value: {other:?}"),
1881 };
1882 let text = match row.get("text") {
1883 Some(SqlValue::Text(s)) => s.clone(),
1884 other => panic!("unexpected text value: {other:?}"),
1885 };
1886 (role, text)
1887 })
1888 .collect();
1889 assert_eq!(
1890 texts,
1891 vec![
1892 ("user".to_string(), "Hello from Codex".to_string()),
1893 ("assistant".to_string(), "Hello back from Codex".to_string()),
1894 ],
1895 "input_text/output_text blocks must round-trip to session_messages.text by role"
1896 );
1897 }
1898
1899 #[tokio::test]
1900 async fn test_codex_event_id_is_stable_and_idempotent() {
1901 let (rt, _dir) = setup().await;
1904
1905 let session_id = "cdx-sess-idem-0001-0001-000000000002";
1906 let user_msg = codex_message_line("user", "Idempotency test");
1907
1908 let mut file = NamedTempFile::new().expect("tmpfile");
1909 writeln!(file, "{user_msg}").unwrap();
1910
1911 let path = file.path().to_path_buf();
1912
1913 let s1 = mirror_file(&rt, &path, 0, LineTailSource::Codex, Some(session_id))
1915 .await
1916 .unwrap();
1917 assert_eq!(s1.inserted, 1);
1918
1919 let sql = rt.sql();
1921 let mut r = sql.reader().await.expect("reader");
1922 let msg_row = r
1923 .query_row(SqlStatement {
1924 sql: "SELECT id FROM session_messages WHERE session_id=?1".into(),
1925 params: vec![SqlValue::Text(session_id.to_string())],
1926 label: None,
1927 })
1928 .await
1929 .expect("query ok")
1930 .expect("message row must exist");
1931 let stored_id = match msg_row.columns.first().map(|c| &c.value) {
1932 Some(SqlValue::Text(s)) => s.clone(),
1933 other => panic!("unexpected id type: {other:?}"),
1934 };
1935 let expected_id = format!("{session_id}:0");
1937 assert_eq!(
1938 stored_id, expected_id,
1939 "synthetic uuid must be {{session_id}}:{{offset}}"
1940 );
1941
1942 let s2 = mirror_file(&rt, &path, 0, LineTailSource::Codex, Some(session_id))
1944 .await
1945 .unwrap();
1946 assert_eq!(s2.inserted, 0, "second pass must be idempotent");
1947 assert_eq!(count_rows(&rt, "session_messages").await, 1);
1948
1949 let s3 = mirror_file(
1951 &rt,
1952 &path,
1953 s1.new_offset,
1954 LineTailSource::Codex,
1955 Some(session_id),
1956 )
1957 .await
1958 .unwrap();
1959 assert_eq!(s3.inserted, 0, "incremental pass finds nothing new");
1960 }
1961
1962 #[tokio::test]
1963 async fn test_codex_and_cc_are_independent_sessions() {
1964 let (rt, _dir) = setup().await;
1966
1967 let cc_line = user_line("cc-uuid-1", "cc-sess-1", "from claude code");
1968 let mut cc_file = NamedTempFile::new().expect("cc tmpfile");
1969 writeln!(cc_file, "{cc_line}").unwrap();
1970
1971 let cdx_session_id = "cdx-sess-coex-0001-0001-000000000003";
1972 let cdx_msg = codex_message_line("user", "from codex");
1973 let mut cdx_file = NamedTempFile::new().expect("cdx tmpfile");
1974 writeln!(cdx_file, "{cdx_msg}").unwrap();
1975
1976 mirror_file(&rt, cc_file.path(), 0, LineTailSource::ClaudeCode, None)
1977 .await
1978 .unwrap();
1979
1980 mirror_file(
1981 &rt,
1982 cdx_file.path(),
1983 0,
1984 LineTailSource::Codex,
1985 Some(cdx_session_id),
1986 )
1987 .await
1988 .unwrap();
1989
1990 assert_eq!(count_rows(&rt, "sessions").await, 2);
1991 assert_eq!(count_rows(&rt, "session_messages").await, 2);
1992
1993 let sql = rt.sql();
1995 let mut r = sql.reader().await.expect("reader");
1996 let rows = r
1997 .query_all(SqlStatement {
1998 sql: "SELECT source FROM sessions ORDER BY source".into(),
1999 params: vec![],
2000 label: None,
2001 })
2002 .await
2003 .expect("query ok");
2004 let sources: Vec<String> = rows
2005 .iter()
2006 .filter_map(|row| match row.get("source") {
2007 Some(SqlValue::Text(s)) => Some(s.clone()),
2008 _ => None,
2009 })
2010 .collect();
2011 assert_eq!(sources, vec!["claude_code", "codex"]);
2012 }
2013
2014 use serde_json::json;
2022
2023 fn write_export_file(content: &str) -> (NamedTempFile, std::path::PathBuf) {
2024 let mut file = NamedTempFile::new().expect("tmpfile");
2025 write!(file, "{content}").unwrap();
2026 let path = file.path().to_path_buf();
2027 (file, path)
2028 }
2029
2030 fn chatgpt_happy_export_json() -> String {
2031 let conv = json!({
2032 "id": "conv-happy",
2033 "title": "Synthetic Happy",
2034 "create_time": 1_751_462_400.0,
2035 "current_node": "msg-happy-assistant",
2036 "mapping": {
2037 "root-happy": {
2038 "id": "root-happy",
2039 "message": null,
2040 "parent": null,
2041 "children": ["msg-happy-user"]
2042 },
2043 "msg-happy-user": {
2044 "id": "msg-happy-user",
2045 "parent": "root-happy",
2046 "children": ["msg-happy-assistant"],
2047 "message": {
2048 "id": "msg-happy-user",
2049 "author": {"role": "user"},
2050 "create_time": 1_751_462_400.0,
2051 "content": {"content_type": "text", "parts": ["Hello synthetic"]}
2052 }
2053 },
2054 "msg-happy-assistant": {
2055 "id": "msg-happy-assistant",
2056 "parent": "msg-happy-user",
2057 "children": [],
2058 "message": {
2059 "id": "msg-happy-assistant",
2060 "author": {"role": "assistant"},
2061 "create_time": 1_751_462_401.0,
2062 "content": {"content_type": "text", "parts": ["Hi synthetic"]}
2063 }
2064 }
2065 }
2066 });
2067 serde_json::to_string(&json!([conv])).unwrap()
2068 }
2069
2070 #[tokio::test]
2071 async fn test_chatgpt_happy_conversations_json() {
2072 let (rt, _dir) = setup().await;
2073 let (_file, path) = write_export_file(&chatgpt_happy_export_json());
2074 let file_len = std::fs::metadata(&path).unwrap().len();
2075
2076 let stats = mirror_chatgpt_export_file(&rt, &path, 0)
2077 .await
2078 .expect("happy path ingest");
2079 assert_eq!(stats.inserted, 2, "2 message-bearing nodes");
2080 assert_eq!(stats.scanned, 2, "2 events parsed");
2081 assert_eq!(stats.new_offset, file_len, "whole-file cursor-at-length");
2082
2083 let sql = rt.sql();
2084 let mut r = sql.reader().await.expect("reader");
2085 let row = r
2086 .query_row(SqlStatement {
2087 sql: "SELECT source, slug, cwd, git_branch FROM sessions WHERE id='conv-happy'"
2088 .into(),
2089 params: vec![],
2090 label: None,
2091 })
2092 .await
2093 .expect("query ok")
2094 .expect("session row must exist");
2095 match row.get("source") {
2096 Some(SqlValue::Text(s)) => assert_eq!(s, "chatgpt_export"),
2097 other => panic!("unexpected source: {other:?}"),
2098 }
2099 match row.get("slug") {
2100 Some(SqlValue::Text(s)) => assert_eq!(s, "Synthetic Happy"),
2101 other => panic!("unexpected slug: {other:?}"),
2102 }
2103 assert!(
2104 matches!(row.get("cwd"), Some(SqlValue::Null) | None),
2105 "chatgpt export never carries a cwd"
2106 );
2107 assert!(
2108 matches!(row.get("git_branch"), Some(SqlValue::Null) | None),
2109 "chatgpt export never carries a git branch"
2110 );
2111
2112 let mut r2 = sql.reader().await.expect("reader");
2113 let rows = r2
2114 .query_all(SqlStatement {
2115 sql: "SELECT seq, role FROM session_messages \
2116 WHERE session_id='conv-happy' ORDER BY seq"
2117 .into(),
2118 params: vec![],
2119 label: None,
2120 })
2121 .await
2122 .expect("query ok");
2123 let roles: Vec<(i64, String)> = rows
2124 .iter()
2125 .map(|row| {
2126 let seq = match row.get("seq") {
2127 Some(SqlValue::Integer(n)) => *n,
2128 other => panic!("unexpected seq: {other:?}"),
2129 };
2130 let role = match row.get("role") {
2131 Some(SqlValue::Text(s)) => s.clone(),
2132 other => panic!("unexpected role: {other:?}"),
2133 };
2134 (seq, role)
2135 })
2136 .collect();
2137 assert_eq!(
2138 roles,
2139 vec![(0, "user".to_string()), (1, "assistant".to_string())]
2140 );
2141 }
2142
2143 fn chatgpt_idempotency_export_json() -> String {
2144 let conv = json!({
2145 "id": "conv-idem",
2146 "title": "Synthetic Idempotency",
2147 "current_node": "msg-idem-assistant",
2148 "mapping": {
2149 "root-idem": {
2150 "id": "root-idem",
2151 "message": null,
2152 "parent": null,
2153 "children": ["msg-idem-user"]
2154 },
2155 "msg-idem-user": {
2156 "id": "msg-idem-user",
2157 "parent": "root-idem",
2158 "children": ["msg-idem-assistant"],
2159 "message": {
2160 "id": "msg-idem-user",
2161 "author": {"role": "user"},
2162 "content": {"content_type": "text", "parts": ["Same question again"]}
2163 }
2164 },
2165 "msg-idem-assistant": {
2166 "id": "msg-idem-assistant",
2167 "parent": "msg-idem-user",
2168 "children": [],
2169 "message": {
2170 "id": "msg-idem-assistant",
2171 "author": {"role": "assistant"},
2172 "content": {"content_type": "text", "parts": ["Same answer again"]}
2173 }
2174 }
2175 }
2176 });
2177 serde_json::to_string(&json!([conv])).unwrap()
2178 }
2179
2180 #[tokio::test]
2181 async fn test_chatgpt_reingest_idempotency_conversations_json() {
2182 let (rt, _dir) = setup().await;
2183 let (_file, path) = write_export_file(&chatgpt_idempotency_export_json());
2184
2185 let s1 = mirror_chatgpt_export_file(&rt, &path, 0)
2186 .await
2187 .expect("first ingest");
2188 assert_eq!(s1.inserted, 2);
2189
2190 let seen_after_first = last_seen_at(&rt, "conv-idem")
2191 .await
2192 .expect("session row exists");
2193
2194 let s2 = mirror_chatgpt_export_file(&rt, &path, 0)
2197 .await
2198 .expect("second ingest");
2199 assert_eq!(s2.inserted, 0, "re-ingest must insert 0 new rows");
2200
2201 let sql = rt.sql();
2202 let mut r = sql.reader().await.expect("reader");
2203 let count = r
2204 .query_row(SqlStatement {
2205 sql: "SELECT COUNT(*) FROM session_messages WHERE session_id='conv-idem'".into(),
2206 params: vec![],
2207 label: None,
2208 })
2209 .await
2210 .expect("query ok")
2211 .expect("count row");
2212 match count.columns.first().map(|c| &c.value) {
2213 Some(SqlValue::Integer(n)) => assert_eq!(*n, 2, "message count stays at 2"),
2214 other => panic!("unexpected count: {other:?}"),
2215 }
2216
2217 let seen_after_replay = last_seen_at(&rt, "conv-idem")
2218 .await
2219 .expect("session row still exists");
2220 assert_eq!(
2221 seen_after_first, seen_after_replay,
2222 "pure replay must not advance last_seen_at"
2223 );
2224 }
2225
2226 fn chatgpt_branch_sidechain_export_json() -> String {
2227 let conv = json!({
2228 "id": "conv-branch",
2229 "title": "Synthetic Branch",
2230 "current_node": "msg-branch-main",
2231 "mapping": {
2232 "root-branch": {
2233 "id": "root-branch",
2234 "message": null,
2235 "parent": null,
2236 "children": ["msg-branch-user"]
2237 },
2238 "msg-branch-user": {
2239 "id": "msg-branch-user",
2240 "parent": "root-branch",
2241 "children": ["msg-branch-main", "msg-branch-alt"],
2242 "message": {
2243 "id": "msg-branch-user",
2244 "author": {"role": "user"},
2245 "content": {"content_type": "text", "parts": ["Branch question"]}
2246 }
2247 },
2248 "msg-branch-main": {
2249 "id": "msg-branch-main",
2250 "parent": "msg-branch-user",
2251 "children": [],
2252 "message": {
2253 "id": "msg-branch-main",
2254 "author": {"role": "assistant"},
2255 "content": {"content_type": "text", "parts": ["Main answer"]}
2256 }
2257 },
2258 "msg-branch-alt": {
2259 "id": "msg-branch-alt",
2260 "parent": "msg-branch-user",
2261 "children": [],
2262 "message": {
2263 "id": "msg-branch-alt",
2264 "author": {"role": "assistant"},
2265 "content": {"content_type": "text", "parts": ["Alternate answer"]}
2266 }
2267 }
2268 }
2269 });
2270 serde_json::to_string(&json!([conv])).unwrap()
2271 }
2272
2273 #[tokio::test]
2274 async fn test_chatgpt_branch_sidechain_conversations_json() {
2275 let (rt, _dir) = setup().await;
2276 let (_file, path) = write_export_file(&chatgpt_branch_sidechain_export_json());
2277
2278 let stats = mirror_chatgpt_export_file(&rt, &path, 0)
2279 .await
2280 .expect("branch ingest");
2281 assert_eq!(stats.inserted, 3, "user + main + alt all stored");
2282
2283 let sql = rt.sql();
2284 let mut r = sql.reader().await.expect("reader");
2285 let rows = r
2286 .query_all(SqlStatement {
2287 sql: "SELECT id, is_sidechain, text FROM session_messages \
2288 WHERE session_id='conv-branch' ORDER BY id"
2289 .into(),
2290 params: vec![],
2291 label: None,
2292 })
2293 .await
2294 .expect("query ok");
2295 assert_eq!(rows.len(), 3);
2296
2297 for row in &rows {
2298 let id = match row.get("id") {
2299 Some(SqlValue::Text(s)) => s.clone(),
2300 other => panic!("unexpected id: {other:?}"),
2301 };
2302 let is_sidechain = match row.get("is_sidechain") {
2303 Some(SqlValue::Integer(n)) => *n,
2304 other => panic!("unexpected is_sidechain: {other:?}"),
2305 };
2306 let text = match row.get("text") {
2307 Some(SqlValue::Text(s)) => s.clone(),
2308 other => panic!("unexpected text: {other:?}"),
2309 };
2310 match id.as_str() {
2311 "msg-branch-user" | "msg-branch-main" => {
2312 assert_eq!(is_sidechain, 0, "{id} is on the current-node path")
2313 }
2314 "msg-branch-alt" => {
2315 assert_eq!(is_sidechain, 1, "alt branch is off the current-node path");
2316 assert_eq!(
2317 text, "Alternate answer",
2318 "sidechain content must be preserved, not dropped"
2319 );
2320 }
2321 other => panic!("unexpected message id: {other}"),
2322 }
2323 }
2324 }
2325
2326 #[tokio::test]
2327 async fn test_chatgpt_malformed_conversations_json_cursor_does_not_advance() {
2328 let (rt, _dir) = setup().await;
2329
2330 let (mut file, path) = write_export_file("[]");
2332 let seeded_stats = mirror_chatgpt_export_file(&rt, &path, 0)
2333 .await
2334 .expect("seeding with an empty array is a valid parse");
2335 assert_eq!(seeded_stats.inserted, 0);
2336 let seeded_offset = seeded_stats.new_offset;
2337
2338 let seeded_sessions = count_rows(&rt, "sessions").await;
2339 let seeded_messages = count_rows(&rt, "session_messages").await;
2340
2341 let malformed = r#"{"oops": "not a chatgpt export array"}"#;
2343 file.as_file_mut().set_len(0).expect("truncate");
2344 std::io::Seek::seek(file.as_file_mut(), std::io::SeekFrom::Start(0)).unwrap();
2345 write!(file, "{malformed}").unwrap();
2346
2347 let result = mirror_chatgpt_export_file(&rt, &path, seeded_offset).await;
2348 assert!(
2349 matches!(result, Err(RuntimeError::Internal(_))),
2350 "malformed export must return Internal error, got {result:?}"
2351 );
2352
2353 let stored_offset = cursor_offset(&rt, &path.to_string_lossy()).await;
2354 assert_eq!(
2355 stored_offset,
2356 Some(seeded_offset as i64),
2357 "cursor must remain at the pre-error value"
2358 );
2359 assert_eq!(
2360 count_rows(&rt, "sessions").await,
2361 seeded_sessions,
2362 "no new session rows on parse failure"
2363 );
2364 assert_eq!(
2365 count_rows(&rt, "session_messages").await,
2366 seeded_messages,
2367 "no new message rows on parse failure"
2368 );
2369 }
2370
2371 #[tokio::test]
2372 async fn test_chatgpt_export_over_max_bytes_is_skipped_without_reading() {
2373 let (rt, _dir) = setup().await;
2379 let (_file, path) = write_export_file("[]");
2380
2381 let file_len = std::fs::metadata(&path).unwrap().len();
2382 let max_bytes = 1u64; assert!(
2384 file_len > max_bytes,
2385 "fixture export must exceed the tiny ceiling"
2386 );
2387
2388 let stats = mirror_chatgpt_export_file_with_max_bytes(&rt, &path, 0, max_bytes)
2389 .await
2390 .expect("an oversized export must be skipped, not error");
2391
2392 assert_eq!(stats.inserted, 0);
2393 assert_eq!(stats.scanned, 0);
2394 assert_eq!(
2395 stats.new_offset, 0,
2396 "cursor must not advance past a skipped oversized export"
2397 );
2398 assert_eq!(
2399 cursor_offset(&rt, &path.to_string_lossy()).await,
2400 None,
2401 "no cursor row should be written for a skipped pass"
2402 );
2403 assert_eq!(count_rows(&rt, "sessions").await, 0);
2404 assert_eq!(count_rows(&rt, "session_messages").await, 0);
2405 }
2406
2407 #[tokio::test]
2408 async fn test_chatgpt_secret_bearing_conversations_json_is_masked() {
2409 let secret_fragment_a = "AKIA";
2413 let secret_fragment_b = "FAKEKEY1234567890";
2414 let secret = format!("{secret_fragment_a}{secret_fragment_b}");
2415 let user_text = format!("here is my key: {secret}");
2416
2417 let conv = json!({
2418 "id": "conv-secret",
2419 "title": "Synthetic Secret",
2420 "current_node": "msg-secret-user",
2421 "mapping": {
2422 "root-secret": {
2423 "id": "root-secret",
2424 "message": null,
2425 "parent": null,
2426 "children": ["msg-secret-user"]
2427 },
2428 "msg-secret-user": {
2429 "id": "msg-secret-user",
2430 "parent": "root-secret",
2431 "children": [],
2432 "message": {
2433 "id": "msg-secret-user",
2434 "author": {"role": "user"},
2435 "content": {"content_type": "text", "parts": [user_text]}
2436 }
2437 }
2438 }
2439 });
2440 let content = serde_json::to_string(&json!([conv])).unwrap();
2441 let (_file, path) = write_export_file(&content);
2442
2443 let (rt, _dir) = setup().await;
2444 let stats = mirror_chatgpt_export_file(&rt, &path, 0)
2445 .await
2446 .expect("secret-bearing content must still ingest, only masked");
2447 assert_eq!(stats.inserted, 1);
2448
2449 let sql = rt.sql();
2450 let mut r = sql.reader().await.expect("reader");
2451 let row = r
2452 .query_row(SqlStatement {
2453 sql: "SELECT text, raw FROM session_messages WHERE session_id='conv-secret'".into(),
2454 params: vec![],
2455 label: None,
2456 })
2457 .await
2458 .expect("query ok")
2459 .expect("message row must exist");
2460 let (stored_text, stored_raw) = match (row.get("text"), row.get("raw")) {
2461 (Some(SqlValue::Text(t)), Some(SqlValue::Text(r))) => (t.clone(), r.clone()),
2462 other => panic!("unexpected text/raw shape: {other:?}"),
2463 };
2464
2465 assert!(
2466 !stored_text.contains(&secret),
2467 "stored text must not contain the raw secret"
2468 );
2469 assert!(
2470 !stored_raw.contains(&secret),
2471 "stored raw must not contain the raw secret"
2472 );
2473 assert!(
2474 stored_text.contains("***MASKED***"),
2475 "stored text must carry the secret_gate redaction marker"
2476 );
2477 assert!(
2478 stored_raw.contains("***MASKED***"),
2479 "stored raw must carry the secret_gate redaction marker"
2480 );
2481 }
2482
2483 #[tokio::test]
2502 async fn test_mid_transaction_db_error_leaves_no_partial_state_and_cursor_unadvanced() {
2503 let (rt, _dir) = setup().await;
2504 let sql = rt.sql();
2505 let path = std::path::Path::new("/synthetic/mid-tx-probe.json");
2506 let path_owned = path.to_path_buf();
2507
2508 let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
2509 Box::pin(async move {
2510 writer
2513 .execute(SqlStatement {
2514 sql: "INSERT INTO sessions \
2515 (id, provider_session_id, source, message_count, first_seen_at, last_seen_at, namespace) \
2516 VALUES('mid-tx-session', 'mid-tx-session', 'chatgpt_export', 0, 1, 1, 'local')"
2517 .into(),
2518 params: vec![],
2519 label: None,
2520 })
2521 .await?;
2522
2523 upsert_cursor_on_writer(writer, &path_owned, Some("mid-tx-session"), 999, 1)
2526 .await?;
2527
2528 writer
2531 .execute(SqlStatement {
2532 sql: "INSERT INTO no_such_table_mid_tx_probe(a) VALUES(1)".into(),
2533 params: vec![],
2534 label: None,
2535 })
2536 .await?;
2537
2538 Ok(Box::new(()) as Box<dyn std::any::Any + Send>)
2539 })
2540 });
2541
2542 let result = sql.atomic_unit(op).await;
2546 assert!(
2547 result.is_err(),
2548 "atomic_unit must propagate the forced third-write failure"
2549 );
2550
2551 assert_eq!(
2552 count_rows(&rt, "sessions").await,
2553 0,
2554 "session write must not survive a later error in the same atomic unit"
2555 );
2556 assert_eq!(
2557 cursor_offset(&rt, &path.to_string_lossy()).await,
2558 None,
2559 "cursor must not advance when a later write in the same atomic unit fails"
2560 );
2561 }
2562
2563 fn write_queue_pool(db_path: std::path::PathBuf) -> Arc<khive_db::ConnectionPool> {
2574 let pool_cfg = khive_db::PoolConfig {
2575 path: Some(db_path),
2576 write_queue_enabled: true,
2577 ..khive_db::PoolConfig::default()
2578 };
2579 let pool = Arc::new(khive_db::ConnectionPool::new(pool_cfg).expect("pool"));
2580 {
2581 let w_conn = pool.writer().expect("writer");
2582 for stmt in &SESSION_SCHEMA_PLAN_STMTS {
2583 w_conn
2584 .conn()
2585 .execute_batch(stmt)
2586 .expect("session schema stmt");
2587 }
2588 }
2589 pool
2590 }
2591
2592 #[tokio::test]
2602 async fn write_events_and_cursor_is_suspension_free_under_single_writer() {
2603 let dir = TempDir::new().expect("tempdir");
2604 let pool = write_queue_pool(dir.path().join("suspend_free.db"));
2605 let sql: Arc<dyn khive_storage::SqlAccess> =
2606 Arc::new(khive_db::SqlBridge::new(Arc::clone(&pool), true));
2607
2608 pool.writer_task_handle()
2609 .unwrap()
2610 .expect("writer task must be spawned with the flag on for a file-backed pool");
2611
2612 let events = vec![parse::parse_cc_line(
2613 r#"{"uuid":"evt-1","sessionId":"suspend-free-session","type":"user","message":{"role":"user","content":"hello"},"cwd":"/tmp","timestamp":"2026-01-01T00:00:00Z"}"#,
2614 )
2615 .expect("line must parse")];
2616
2617 let path = std::path::Path::new("/synthetic/suspend-free.jsonl").to_path_buf();
2618 let now_us = Utc::now().timestamp_micros();
2619 let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
2620 Box::pin(async move {
2621 write_events_and_cursor_on_writer(
2622 writer,
2623 &path,
2624 "claude_code",
2625 &events,
2626 1,
2627 100,
2628 now_us,
2629 )
2630 .await
2631 .map(|stats| Box::new(stats) as Box<dyn std::any::Any + Send>)
2632 .map_err(|e| {
2633 khive_storage::StorageError::driver(
2634 khive_storage::StorageCapability::Sql,
2635 "test_write_events_and_cursor",
2636 e,
2637 )
2638 })
2639 })
2640 });
2641
2642 let boxed = sql
2643 .atomic_unit(op)
2644 .await
2645 .expect("a suspension-free closure must not hit block_on_sync's Pending error");
2646 let stats = *boxed
2647 .downcast::<MirrorStats>()
2648 .expect("op must return MirrorStats");
2649
2650 assert_eq!(stats.inserted, 1, "the one event must be inserted");
2651
2652 let mut reader = sql.reader().await.expect("reader");
2653 let row = khive_storage::SqlReader::query_scalar(
2654 reader.as_mut(),
2655 SqlStatement {
2656 sql: "SELECT COUNT(*) FROM sessions".into(),
2657 params: vec![],
2658 label: None,
2659 },
2660 )
2661 .await
2662 .expect("count query")
2663 .expect("count row");
2664 match row {
2665 SqlValue::Integer(1) => {}
2666 other => panic!("the session row must be committed, got COUNT(*) = {other:?}"),
2667 }
2668 }
2669
2670 #[tokio::test]
2686 async fn session_ingest_routes_through_writer_task_when_flag_enabled() {
2687 let dir = TempDir::new().expect("tempdir");
2688 let pool = write_queue_pool(dir.path().join("concurrency.db"));
2689 let sql: Arc<dyn khive_storage::SqlAccess> =
2690 Arc::new(khive_db::SqlBridge::new(Arc::clone(&pool), true));
2691
2692 let writer_task = pool
2693 .writer_task_handle()
2694 .unwrap()
2695 .expect("writer task must be spawned with the flag on for a file-backed pool");
2696
2697 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
2698 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
2699 let occupier = {
2700 let writer_task = writer_task.clone();
2701 tokio::spawn(async move {
2702 writer_task
2703 .send(move |_conn| {
2704 let _ = started_tx.send(());
2705 let _ = release_rx.blocking_recv();
2706 Ok::<(), khive_storage::StorageError>(())
2707 })
2708 .await
2709 })
2710 };
2711
2712 started_rx
2713 .await
2714 .expect("occupier must signal it has started running inside the writer task");
2715 assert_eq!(
2716 writer_task.queue_depth(),
2717 0,
2718 "channel must start empty once the occupier has been dequeued and is running"
2719 );
2720
2721 let events = vec![parse::parse_cc_line(
2722 r#"{"uuid":"evt-concurrency-1","sessionId":"concurrency-session","type":"user","message":{"role":"user","content":"hello"},"cwd":"/tmp","timestamp":"2026-01-01T00:00:00Z"}"#,
2723 )
2724 .expect("line must parse")];
2725 let path = std::path::Path::new("/synthetic/concurrency.jsonl").to_path_buf();
2726 let now_us = Utc::now().timestamp_micros();
2727 let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
2728 Box::pin(async move {
2729 write_events_and_cursor_on_writer(
2730 writer,
2731 &path,
2732 "claude_code",
2733 &events,
2734 1,
2735 100,
2736 now_us,
2737 )
2738 .await
2739 .map(|stats| Box::new(stats) as Box<dyn std::any::Any + Send>)
2740 .map_err(|e| {
2741 khive_storage::StorageError::driver(
2742 khive_storage::StorageCapability::Sql,
2743 "test_session_ingest_concurrency",
2744 e,
2745 )
2746 })
2747 })
2748 });
2749
2750 let sql_for_ingest = Arc::clone(&sql);
2751 let ingest_task = tokio::spawn(async move { sql_for_ingest.atomic_unit(op).await });
2752
2753 let mut saw_enqueued = false;
2754 for _ in 0..100 {
2755 if writer_task.queue_depth() >= 1 {
2756 saw_enqueued = true;
2757 break;
2758 }
2759 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2760 }
2761 assert!(
2762 saw_enqueued,
2763 "session-ingest's atomic_unit request never appeared in the writer task's \
2764 channel while the occupier held the single drain slot — the converted ingest \
2765 path is not routing through the shared writer task (a standalone `begin_tx` \
2766 connection would never show up here at all)"
2767 );
2768
2769 release_tx
2770 .send(())
2771 .expect("occupier must still be waiting on the release signal");
2772 occupier
2773 .await
2774 .expect("occupier task must not panic")
2775 .expect("occupier write must succeed");
2776
2777 let boxed = ingest_task
2778 .await
2779 .expect("ingest task must not panic")
2780 .expect("ingest atomic_unit must succeed once the occupier releases the slot");
2781 let stats = *boxed
2782 .downcast::<MirrorStats>()
2783 .expect("op must return MirrorStats");
2784 assert_eq!(stats.inserted, 1, "the ingest event must be committed");
2785 }
2786
2787 #[tokio::test]
2799 async fn old_shape_manual_begin_immediate_inside_atomic_unit_fails() {
2800 let dir = TempDir::new().expect("tempdir");
2801 let pool = write_queue_pool(dir.path().join("old_shape_begin_immediate.db"));
2802 let sql: Arc<dyn khive_storage::SqlAccess> =
2803 Arc::new(khive_db::SqlBridge::new(Arc::clone(&pool), true));
2804
2805 pool.writer_task_handle()
2806 .unwrap()
2807 .expect("writer task must be spawned with the flag on for a file-backed pool");
2808
2809 let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
2810 Box::pin(async move {
2811 writer
2817 .execute(SqlStatement {
2818 sql: "BEGIN IMMEDIATE".into(),
2819 params: vec![],
2820 label: None,
2821 })
2822 .await?;
2823 Ok(Box::new(()) as Box<dyn std::any::Any + Send>)
2824 })
2825 });
2826
2827 let err = sql.atomic_unit(op).await.expect_err(
2828 "a closure that issues its own BEGIN IMMEDIATE inside atomic_unit must fail with a \
2829 nested-transaction error, not silently succeed",
2830 );
2831 let msg = err.to_string();
2832 assert!(
2833 msg.contains("cannot start a transaction within a transaction"),
2834 "expected the deterministic nested-transaction failure (SQLite's own message for a \
2835 second BEGIN issued inside an already-open transaction), got: {msg}"
2836 );
2837 }
2838}