1use std::io::{BufRead, Seek, SeekFrom};
14use std::path::{Path, PathBuf};
15
16use chrono::Utc;
17use khive_runtime::{KhiveRuntime, RuntimeError};
18use khive_storage::types::{SqlStatement, SqlValue};
19use khive_storage::SqlWriter;
20
21use super::parse;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum MirrorSource {
33 ClaudeCode,
35 Codex,
37 ChatGptExport,
39}
40
41impl MirrorSource {
42 pub fn as_str(self) -> &'static str {
44 match self {
45 MirrorSource::ClaudeCode => "claude_code",
46 MirrorSource::Codex => "codex",
47 MirrorSource::ChatGptExport => "chatgpt_export",
48 }
49 }
50}
51
52impl From<LineTailSource> for MirrorSource {
53 fn from(source: LineTailSource) -> Self {
54 match source {
55 LineTailSource::ClaudeCode => MirrorSource::ClaudeCode,
56 LineTailSource::Codex => MirrorSource::Codex,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum LineTailSource {
70 ClaudeCode,
72 Codex,
74}
75
76#[derive(Debug, Default, Clone, PartialEq)]
78pub struct MirrorStats {
79 pub inserted: u64,
81 pub scanned: u64,
83 pub new_offset: u64,
85}
86
87const MIRROR_MAX_BYTES_PER_PASS: usize = 8 * 1024 * 1024;
91
92const MIRROR_MAX_EVENTS_PER_PASS: usize = 1024;
94
95const MIRROR_MAX_LINE_BYTES: usize = MIRROR_MAX_BYTES_PER_PASS;
98
99#[derive(Clone, Copy, Debug)]
103struct MirrorLimits {
104 max_bytes_per_pass: usize,
105 max_events_per_pass: usize,
106 max_line_bytes: usize,
107}
108
109impl MirrorLimits {
110 fn production() -> Self {
111 Self {
112 max_bytes_per_pass: MIRROR_MAX_BYTES_PER_PASS,
113 max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
114 max_line_bytes: MIRROR_MAX_LINE_BYTES,
115 }
116 }
117}
118
119pub async fn mirror_file(
137 runtime: &KhiveRuntime,
138 path: &Path,
139 start_offset: u64,
140 source: LineTailSource,
141 codex_session_id: Option<&str>,
142) -> Result<MirrorStats, RuntimeError> {
143 mirror_file_with_limits(
144 runtime,
145 path,
146 start_offset,
147 source,
148 codex_session_id,
149 MirrorLimits::production(),
150 )
151 .await
152}
153
154struct MirrorChunk {
157 events: Vec<parse::ParsedEvent>,
158 scanned: u64,
159 new_offset: u64,
160}
161
162#[derive(Debug)]
166enum LineRead {
167 Eof,
169 Partial,
171 Complete { bytes: usize },
173 Oversized { bytes: usize },
176 OversizedUnterminated { bytes: usize },
179}
180
181fn read_line_bounded(
186 reader: &mut impl BufRead,
187 buf: &mut Vec<u8>,
188 max_line_bytes: usize,
189) -> std::io::Result<LineRead> {
190 let mut total: usize = 0;
191 let mut oversized = false;
192
193 loop {
194 let available = reader.fill_buf()?;
195 if available.is_empty() {
196 return Ok(if total == 0 {
197 LineRead::Eof
198 } else {
199 LineRead::Partial
200 });
201 }
202
203 let newline_pos = available.iter().position(|&b| b == b'\n');
204 let take = newline_pos.map_or(available.len(), |pos| pos + 1);
205
206 if !oversized {
207 if total + take > max_line_bytes {
208 oversized = true;
209 } else {
210 buf.extend_from_slice(&available[..take]);
211 }
212 }
213
214 total += take;
215 reader.consume(take);
216
217 if newline_pos.is_some() {
218 return Ok(if oversized {
219 LineRead::Oversized { bytes: total }
220 } else {
221 LineRead::Complete { bytes: total }
222 });
223 }
224
225 if oversized {
226 return Ok(LineRead::OversizedUnterminated { bytes: total });
231 }
232 }
235}
236
237fn read_bounded_chunk(
243 path: &Path,
244 start_offset: u64,
245 source: LineTailSource,
246 codex_session_id: Option<&str>,
247 limits: MirrorLimits,
248) -> std::io::Result<MirrorChunk> {
249 let mut file = std::fs::File::open(path)?;
250 let file_len = file.metadata()?.len();
251 if start_offset >= file_len {
252 return Ok(MirrorChunk {
253 events: Vec::new(),
254 scanned: 0,
255 new_offset: start_offset,
256 });
257 }
258
259 file.seek(SeekFrom::Start(start_offset))?;
260 let mut reader = std::io::BufReader::new(file);
261 let mut line = Vec::new();
262 let mut events = Vec::new();
263 let mut scanned: u64 = 0;
264 let mut lines_consumed: u64 = 0;
265 let mut new_offset = start_offset;
266 let mut bytes_this_pass: usize = 0;
267
268 loop {
269 if lines_consumed > 0
270 && (bytes_this_pass >= limits.max_bytes_per_pass
271 || events.len() >= limits.max_events_per_pass)
272 {
273 break;
274 }
275
276 line.clear();
277 let line_offset = new_offset;
278
279 match read_line_bounded(&mut reader, &mut line, limits.max_line_bytes)? {
280 LineRead::Eof => break,
281 LineRead::Partial => break, LineRead::OversizedUnterminated { bytes } => {
283 tracing::warn!(
294 path = %path.display(),
295 offset = line_offset,
296 line_bytes = bytes,
297 max_line_bytes = limits.max_line_bytes,
298 "session mirror: oversized JSONL line has no terminator in this bounded read; \
299 leaving cursor at line start for a bounded retry"
300 );
301 break;
302 }
303 LineRead::Oversized { bytes } => {
304 tracing::warn!(
305 path = %path.display(),
306 offset = line_offset,
307 line_bytes = bytes,
308 max_line_bytes = limits.max_line_bytes,
309 "session mirror: skipping oversized JSONL line"
310 );
311 new_offset += bytes as u64;
312 bytes_this_pass += bytes;
313 lines_consumed += 1;
314 }
315 LineRead::Complete { bytes } => {
316 new_offset += bytes as u64;
317 bytes_this_pass += bytes;
318 lines_consumed += 1;
319
320 let raw = String::from_utf8_lossy(&line[..line.len() - 1]);
321 if raw.is_empty() {
322 continue; }
324
325 match source {
326 LineTailSource::ClaudeCode => {
327 if let Some(ev) = parse::parse_cc_line(&raw) {
328 events.push(ev);
329 }
330 }
331 LineTailSource::Codex => {
332 let sid = codex_session_id.unwrap_or("");
333 if let Some(ev) = parse::parse_codex_line(&raw, sid, line_offset) {
334 events.push(ev);
335 }
336 }
337 }
338 scanned += 1;
339 }
340 }
341 }
342
343 Ok(MirrorChunk {
344 events,
345 scanned,
346 new_offset,
347 })
348}
349
350async fn mirror_file_with_limits(
352 runtime: &KhiveRuntime,
353 path: &Path,
354 start_offset: u64,
355 source: LineTailSource,
356 codex_session_id: Option<&str>,
357 limits: MirrorLimits,
358) -> Result<MirrorStats, RuntimeError> {
359 let chunk =
360 read_bounded_chunk(path, start_offset, source, codex_session_id, limits).map_err(|e| {
361 RuntimeError::Internal(format!(
362 "mirror_file: failed to read {:?} at offset {start_offset}: {e}",
363 path
364 ))
365 })?;
366
367 if chunk.new_offset == start_offset {
368 return Ok(MirrorStats {
371 inserted: 0,
372 scanned: 0,
373 new_offset: chunk.new_offset,
374 });
375 }
376
377 if chunk.events.is_empty() {
378 write_cursor_only(runtime, path, &None, chunk.new_offset).await?;
387 return Ok(MirrorStats {
388 inserted: 0,
389 scanned: chunk.scanned,
390 new_offset: chunk.new_offset,
391 });
392 }
393
394 write_events_and_cursor(
395 runtime,
396 path,
397 MirrorSource::from(source).as_str(),
398 &chunk.events,
399 chunk.scanned,
400 chunk.new_offset,
401 )
402 .await
403}
404
405const DEFAULT_CHATGPT_MAX_BYTES: u64 = 256 * 1024 * 1024;
412
413fn chatgpt_max_bytes() -> u64 {
418 std::env::var("KHIVE_MIRROR_CHATGPT_MAX_BYTES")
419 .ok()
420 .and_then(|v| v.parse::<u64>().ok())
421 .filter(|&n| n > 0)
422 .unwrap_or(DEFAULT_CHATGPT_MAX_BYTES)
423}
424
425pub async fn mirror_chatgpt_export_file(
441 runtime: &KhiveRuntime,
442 path: &Path,
443 start_offset: u64,
444) -> Result<MirrorStats, RuntimeError> {
445 mirror_chatgpt_export_file_with_max_bytes(runtime, path, start_offset, chatgpt_max_bytes())
446 .await
447}
448
449async fn mirror_chatgpt_export_file_with_max_bytes(
453 runtime: &KhiveRuntime,
454 path: &Path,
455 start_offset: u64,
456 max_bytes: u64,
457) -> Result<MirrorStats, RuntimeError> {
458 let file_len = std::fs::metadata(path).map(|m| m.len()).map_err(|e| {
459 RuntimeError::Internal(format!(
460 "mirror_chatgpt_export_file: failed to stat {path:?}: {e}"
461 ))
462 })?;
463
464 if file_len <= start_offset {
465 return Ok(MirrorStats {
466 inserted: 0,
467 scanned: 0,
468 new_offset: start_offset,
469 });
470 }
471
472 if file_len > max_bytes {
473 tracing::warn!(
474 path = %path.display(),
475 file_bytes = file_len,
476 max_bytes,
477 "session mirror: skipping oversized ChatGPT export (exceeds KHIVE_MIRROR_CHATGPT_MAX_BYTES)"
478 );
479 return Ok(MirrorStats {
480 inserted: 0,
481 scanned: 0,
482 new_offset: start_offset,
483 });
484 }
485
486 let content = std::fs::read_to_string(path).map_err(|e| {
487 RuntimeError::Internal(format!(
488 "mirror_chatgpt_export_file: failed to read {path:?}: {e}"
489 ))
490 })?;
491
492 let events = parse::parse_chatgpt_export(&content).ok_or_else(|| {
493 RuntimeError::Internal(format!(
494 "mirror_chatgpt_export_file: {path:?} is not a valid ChatGPT export (expected a top-level JSON array)"
495 ))
496 })?;
497
498 let scanned = events.len() as u64;
499
500 write_events_and_cursor(
501 runtime,
502 path,
503 MirrorSource::ChatGptExport.as_str(),
504 &events,
505 scanned,
506 file_len,
507 )
508 .await
509}
510
511async fn write_events_and_cursor(
517 runtime: &KhiveRuntime,
518 path: &Path,
519 source_value: &'static str,
520 events: &[parse::ParsedEvent],
521 scanned: u64,
522 new_offset: u64,
523) -> Result<MirrorStats, RuntimeError> {
524 let now_us = Utc::now().timestamp_micros();
525 let sql = runtime.sql();
526
527 let events_owned: Vec<parse::ParsedEvent> = events.to_vec();
528 let path_owned: PathBuf = path.to_path_buf();
529
530 let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
531 Box::pin(async move {
532 write_events_and_cursor_on_writer(
533 writer,
534 &path_owned,
535 source_value,
536 &events_owned,
537 scanned,
538 new_offset,
539 now_us,
540 )
541 .await
542 .map(|stats| Box::new(stats) as Box<dyn std::any::Any + Send>)
543 .map_err(|e| {
544 khive_storage::StorageError::driver(
545 khive_storage::StorageCapability::Sql,
546 "session_mirror_write_events_and_cursor",
547 e,
548 )
549 })
550 })
551 });
552
553 let boxed = sql
554 .atomic_unit(op)
555 .await
556 .map_err(|e| RuntimeError::Internal(format!("mirror: atomic_unit: {e}")))?;
557
558 Ok(*boxed.downcast::<MirrorStats>().unwrap_or_else(|_| {
559 panic!("atomic_unit op for write_events_and_cursor must return MirrorStats")
560 }))
561}
562
563async fn write_events_and_cursor_on_writer(
569 writer: &mut dyn SqlWriter,
570 path: &Path,
571 source_value: &'static str,
572 events: &[parse::ParsedEvent],
573 scanned: u64,
574 new_offset: u64,
575 now_us: i64,
576) -> khive_storage::types::StorageResult<MirrorStats> {
577 let mut inserted: u64 = 0;
578 let mut last_session_id: Option<String> = None;
579
580 for ev in events {
581 let created_at = if ev.created_at_micros != 0 {
582 ev.created_at_micros
583 } else {
584 now_us
585 };
586
587 writer
590 .execute(SqlStatement {
591 sql: format!(
592 "INSERT INTO sessions \
593 (id, provider_session_id, source, cwd, git_branch, slug, \
594 message_count, first_seen_at, last_seen_at, namespace) \
595 VALUES(?1, ?1, '{}', ?2, ?3, ?4, 0, ?5, ?5, 'local') \
596 ON CONFLICT(id) DO NOTHING",
597 source_value
598 ),
599 params: vec![
600 SqlValue::Text(ev.session_id.clone()),
601 ev.cwd
602 .as_deref()
603 .map(|s| SqlValue::Text(s.to_string()))
604 .unwrap_or(SqlValue::Null),
605 ev.git_branch
606 .as_deref()
607 .map(|s| SqlValue::Text(s.to_string()))
608 .unwrap_or(SqlValue::Null),
609 ev.slug
610 .as_deref()
611 .map(|s| SqlValue::Text(s.to_string()))
612 .unwrap_or(SqlValue::Null),
613 SqlValue::Integer(created_at),
614 ],
615 label: Some("session_mirror_create_session".into()),
616 })
617 .await
618 .map_err(|e| {
619 khive_storage::StorageError::driver(
620 khive_storage::StorageCapability::Sql,
621 "mirror: session create",
622 e,
623 )
624 })?;
625
626 let affected = writer
628 .execute(SqlStatement {
629 sql: "INSERT OR IGNORE INTO session_messages \
630 (id, session_id, seq, parent_uuid, is_sidechain, role, \
631 msg_type, text, raw, created_at, namespace) \
632 VALUES(?1, ?2, \
633 (SELECT COALESCE(MAX(seq),-1)+1 FROM session_messages WHERE session_id=?2), \
634 ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'local')"
635 .into(),
636 params: vec![
637 SqlValue::Text(ev.uuid.clone()),
638 SqlValue::Text(ev.session_id.clone()),
639 ev.parent_uuid
640 .as_deref()
641 .map(|s| SqlValue::Text(s.to_string()))
642 .unwrap_or(SqlValue::Null),
643 SqlValue::Integer(i64::from(ev.is_sidechain)),
644 ev.role
645 .as_deref()
646 .map(|s| SqlValue::Text(s.to_string()))
647 .unwrap_or(SqlValue::Null),
648 SqlValue::Text(ev.msg_type.clone()),
649 ev.text
650 .as_deref()
651 .map(|s| SqlValue::Text(s.to_string()))
652 .unwrap_or(SqlValue::Null),
653 SqlValue::Text(ev.raw.clone()),
654 SqlValue::Integer(created_at),
655 ],
656 label: Some("session_mirror_insert_message".into()),
657 })
658 .await
659 .map_err(|e| {
660 khive_storage::StorageError::driver(
661 khive_storage::StorageCapability::Sql,
662 "mirror: message insert",
663 e,
664 )
665 })?;
666
667 if affected > 0 {
671 writer
672 .execute(SqlStatement {
673 sql: "UPDATE sessions SET \
674 last_seen_at=MAX(last_seen_at, ?2), \
675 cwd=COALESCE(cwd, ?3), \
676 git_branch=COALESCE(git_branch, ?4), \
677 slug=COALESCE(slug, ?5) \
678 WHERE id=?1"
679 .into(),
680 params: vec![
681 SqlValue::Text(ev.session_id.clone()),
682 SqlValue::Integer(created_at),
683 ev.cwd
684 .as_deref()
685 .map(|s| SqlValue::Text(s.to_string()))
686 .unwrap_or(SqlValue::Null),
687 ev.git_branch
688 .as_deref()
689 .map(|s| SqlValue::Text(s.to_string()))
690 .unwrap_or(SqlValue::Null),
691 ev.slug
692 .as_deref()
693 .map(|s| SqlValue::Text(s.to_string()))
694 .unwrap_or(SqlValue::Null),
695 ],
696 label: Some("session_mirror_touch_session".into()),
697 })
698 .await
699 .map_err(|e| {
700 khive_storage::StorageError::driver(
701 khive_storage::StorageCapability::Sql,
702 "mirror: session touch",
703 e,
704 )
705 })?;
706 }
707
708 inserted += affected;
709 last_session_id = Some(ev.session_id.clone());
710 }
711
712 if inserted > 0 {
715 let mut seen_sessions: Vec<String> = events
716 .iter()
717 .map(|e| e.session_id.clone())
718 .collect::<std::collections::HashSet<_>>()
719 .into_iter()
720 .collect();
721 seen_sessions.sort(); for sid in &seen_sessions {
724 writer
725 .execute(SqlStatement {
726 sql: "UPDATE sessions SET message_count=\
727 (SELECT COUNT(*) FROM session_messages WHERE session_id=?1) \
728 WHERE id=?1"
729 .into(),
730 params: vec![SqlValue::Text(sid.clone())],
731 label: Some("session_mirror_refresh_count".into()),
732 })
733 .await
734 .map_err(|e| {
735 khive_storage::StorageError::driver(
736 khive_storage::StorageCapability::Sql,
737 "mirror: count refresh",
738 e,
739 )
740 })?;
741 }
742 }
743
744 upsert_cursor_on_writer(writer, path, last_session_id.as_deref(), new_offset, now_us).await?;
745
746 Ok(MirrorStats {
749 inserted,
750 scanned,
751 new_offset,
752 })
753}
754
755async fn upsert_cursor_on_writer(
759 writer: &mut dyn SqlWriter,
760 path: &Path,
761 session_id: Option<&str>,
762 new_offset: u64,
763 now_us: i64,
764) -> khive_storage::types::StorageResult<()> {
765 let path_str = path.to_string_lossy().into_owned();
766 writer
767 .execute(SqlStatement {
768 sql:
769 "INSERT INTO session_mirror_cursor(file_path, session_id, byte_offset, updated_at) \
770 VALUES(?1, ?2, ?3, ?4) \
771 ON CONFLICT(file_path) DO UPDATE SET \
772 session_id=excluded.session_id, \
773 byte_offset=excluded.byte_offset, \
774 updated_at=excluded.updated_at"
775 .into(),
776 params: vec![
777 SqlValue::Text(path_str),
778 session_id
779 .map(|s| SqlValue::Text(s.to_string()))
780 .unwrap_or(SqlValue::Null),
781 SqlValue::Integer(new_offset as i64),
782 SqlValue::Integer(now_us),
783 ],
784 label: Some("session_mirror_cursor_upsert".into()),
785 })
786 .await
787 .map_err(|e| {
788 khive_storage::StorageError::driver(
789 khive_storage::StorageCapability::Sql,
790 "mirror: cursor upsert",
791 e,
792 )
793 })?;
794 Ok(())
795}
796
797async fn write_cursor_only(
802 runtime: &KhiveRuntime,
803 path: &Path,
804 session_id: &Option<String>,
805 new_offset: u64,
806) -> Result<(), RuntimeError> {
807 let now_us = Utc::now().timestamp_micros();
808 let path_str = path.to_string_lossy().into_owned();
809 let sql = runtime.sql();
810 let mut w = sql
811 .writer()
812 .await
813 .map_err(|e| RuntimeError::Internal(format!("mirror_file: cursor writer: {e}")))?;
814 w.execute(SqlStatement {
815 sql: "INSERT INTO session_mirror_cursor(file_path, session_id, byte_offset, updated_at) \
816 VALUES(?1, ?2, ?3, ?4) \
817 ON CONFLICT(file_path) DO UPDATE SET \
818 session_id=COALESCE(excluded.session_id, session_mirror_cursor.session_id), \
819 byte_offset=excluded.byte_offset, \
820 updated_at=excluded.updated_at"
821 .into(),
822 params: vec![
823 SqlValue::Text(path_str),
824 session_id
825 .as_deref()
826 .map(|s| SqlValue::Text(s.to_string()))
827 .unwrap_or(SqlValue::Null),
828 SqlValue::Integer(new_offset as i64),
829 SqlValue::Integer(now_us),
830 ],
831 label: Some("session_mirror_cursor_only".into()),
832 })
833 .await
834 .map_err(|e| RuntimeError::Internal(format!("mirror_file: cursor write: {e}")))?;
835 Ok(())
836}
837
838#[cfg(test)]
839mod tests {
840 use std::io::Write;
841 use std::sync::Arc;
842
843 use khive_runtime::{
844 AllowAllGate, BackendId, KhiveRuntime, Namespace, RuntimeConfig, RuntimeError,
845 };
846 use khive_storage::types::{SqlStatement, SqlValue};
847 use tempfile::{NamedTempFile, TempDir};
848
849 use super::*;
850 use crate::vocab::SESSION_SCHEMA_PLAN_STMTS;
851
852 async fn setup() -> (KhiveRuntime, TempDir) {
856 let dir = TempDir::new().expect("tempdir");
857 let db_path = dir.path().join("test.db");
858 let rt = KhiveRuntime::new(RuntimeConfig {
859 git_write: Default::default(),
860 db_path: Some(db_path),
861 default_namespace: Namespace::local(),
862 embedding_model: None,
863 additional_embedding_models: vec![],
864 gate: Arc::new(AllowAllGate),
865 packs: vec!["kg".to_string()],
866 backend_id: BackendId::main(),
867 brain_profile: None,
868 visible_namespaces: vec![],
869 allowed_outbound_namespaces: vec![],
870 actor_id: None,
871 })
872 .expect("file-backed runtime");
873 apply_session_schema(&rt).await;
874 (rt, dir)
875 }
876
877 async fn apply_session_schema(rt: &KhiveRuntime) {
878 let sql = rt.sql();
879 let mut w = sql.writer().await.expect("writer");
880 for stmt in &SESSION_SCHEMA_PLAN_STMTS {
881 w.execute_script(stmt.to_string())
882 .await
883 .expect("schema stmt");
884 }
885 }
887
888 async fn count_rows(rt: &KhiveRuntime, table: &str) -> i64 {
890 let sql = rt.sql();
891 let mut r = sql.reader().await.expect("reader");
892 let row = r
893 .query_row(SqlStatement {
894 sql: format!("SELECT COUNT(*) FROM {table}"),
895 params: vec![],
896 label: None,
897 })
898 .await
899 .expect("count query")
900 .expect("count row");
901 match row.columns.first().map(|c| &c.value) {
902 Some(SqlValue::Integer(n)) => *n,
903 _ => 0,
904 }
905 }
906
907 async fn cursor_offset(rt: &KhiveRuntime, path_str: &str) -> Option<i64> {
909 let sql = rt.sql();
910 let mut r = sql.reader().await.expect("reader");
911 let row = r
912 .query_row(SqlStatement {
913 sql: "SELECT byte_offset FROM session_mirror_cursor WHERE file_path=?1".into(),
914 params: vec![SqlValue::Text(path_str.to_string())],
915 label: None,
916 })
917 .await
918 .expect("cursor query")?;
919 match row.columns.first().map(|c| &c.value) {
920 Some(SqlValue::Integer(n)) => Some(*n),
921 _ => None,
922 }
923 }
924
925 fn user_line(uuid: &str, session_id: &str, text: &str) -> String {
926 format!(
927 r#"{{"uuid":"{uuid}","sessionId":"{session_id}","type":"user","timestamp":"2026-06-29T10:00:00Z","message":{{"role":"user","content":"{text}"}}}}"#
928 )
929 }
930
931 fn user_line_no_ts(uuid: &str, session_id: &str, text: &str) -> String {
933 format!(
934 r#"{{"uuid":"{uuid}","sessionId":"{session_id}","type":"user","message":{{"role":"user","content":"{text}"}}}}"#
935 )
936 }
937
938 async fn last_seen_at(rt: &KhiveRuntime, session_id: &str) -> Option<i64> {
940 let sql = rt.sql();
941 let mut r = sql.reader().await.expect("reader");
942 let row = r
943 .query_row(SqlStatement {
944 sql: "SELECT last_seen_at FROM sessions WHERE id=?1".into(),
945 params: vec![SqlValue::Text(session_id.to_string())],
946 label: None,
947 })
948 .await
949 .expect("last_seen query")?;
950 match row.columns.first().map(|c| &c.value) {
951 Some(SqlValue::Integer(n)) => Some(*n),
952 _ => None,
953 }
954 }
955
956 #[tokio::test]
957 async fn test_mirror_three_lines_and_idempotency() {
958 let (rt, _dir) = setup().await;
959
960 let line1 = user_line("uuid-1", "sess-A", "Hello");
962 let line2 = user_line("uuid-2", "sess-A", "World");
963 let line3 = user_line("uuid-3", "sess-A", "Done");
964
965 let mut file = NamedTempFile::new().expect("tmpfile");
966 writeln!(file, "{line1}").unwrap();
967 writeln!(file, "{line2}").unwrap();
968 writeln!(file, "{line3}").unwrap();
969
970 let path = file.path().to_path_buf();
971
972 let stats = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
974 .await
975 .expect("mirror_file first call");
976 assert_eq!(stats.inserted, 3, "should insert 3 new messages");
977 assert_eq!(stats.scanned, 3, "should scan 3 lines");
978 assert!(stats.new_offset > 0, "offset should advance");
979
980 let msg_count = count_rows(&rt, "session_messages").await;
981 assert_eq!(msg_count, 3, "3 messages in DB");
982
983 let session_count = count_rows(&rt, "sessions").await;
984 assert_eq!(session_count, 1, "1 session row");
985
986 let stats2 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
988 .await
989 .expect("mirror_file second call");
990 assert_eq!(stats2.inserted, 0, "second pass must insert 0 rows");
991 assert_eq!(count_rows(&rt, "session_messages").await, 3);
992
993 let stats3 = mirror_file(
995 &rt,
996 &path,
997 stats.new_offset,
998 LineTailSource::ClaudeCode,
999 None,
1000 )
1001 .await
1002 .expect("mirror_file from new_offset");
1003 assert_eq!(stats3.inserted, 0, "no new data past advanced offset");
1004 assert_eq!(stats3.new_offset, stats.new_offset);
1005
1006 let stored_offset = cursor_offset(&rt, &path.to_string_lossy()).await;
1008 assert!(stored_offset.is_some(), "cursor should be recorded");
1009 assert_eq!(stored_offset.unwrap(), stats.new_offset as i64);
1010 }
1011
1012 #[tokio::test]
1013 async fn mirror_file_respects_low_test_cap_and_advances_over_multiple_passes() {
1014 let (rt, _dir) = setup().await;
1016
1017 let lines: Vec<String> = (0..6)
1018 .map(|i| user_line(&format!("uuid-cap-{i}"), "sess-CAP", &format!("line{i}")))
1019 .collect();
1020
1021 let mut file = NamedTempFile::new().expect("tmpfile");
1022 for line in &lines {
1023 writeln!(file, "{line}").unwrap();
1024 }
1025 let path = file.path().to_path_buf();
1026 let file_len = std::fs::metadata(&path).unwrap().len();
1027
1028 let cap_bytes = (lines[0].len() + 1) + (lines[1].len() + 1);
1032 let limits = MirrorLimits {
1033 max_bytes_per_pass: cap_bytes,
1034 max_events_per_pass: 1024,
1035 max_line_bytes: MIRROR_MAX_LINE_BYTES,
1036 };
1037
1038 let stats1 =
1039 mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1040 .await
1041 .expect("first bounded pass");
1042 assert_eq!(
1043 stats1.inserted, 2,
1044 "first pass must stop at the byte cap, not read the whole file"
1045 );
1046 assert_eq!(stats1.scanned, 2);
1047 assert!(
1048 stats1.new_offset < file_len,
1049 "new_offset {new} must be less than file_len {file_len} for a bounded pass",
1050 new = stats1.new_offset
1051 );
1052 assert_eq!(
1053 cursor_offset(&rt, &path.to_string_lossy()).await,
1054 Some(stats1.new_offset as i64),
1055 "cursor must be committed after the first bounded pass"
1056 );
1057
1058 let stats2 = mirror_file_with_limits(
1059 &rt,
1060 &path,
1061 stats1.new_offset,
1062 LineTailSource::ClaudeCode,
1063 None,
1064 limits,
1065 )
1066 .await
1067 .expect("second bounded pass");
1068 assert_eq!(stats2.inserted, 2);
1069 assert!(stats2.new_offset > stats1.new_offset);
1070 assert!(stats2.new_offset < file_len);
1071
1072 let stats3 = mirror_file_with_limits(
1073 &rt,
1074 &path,
1075 stats2.new_offset,
1076 LineTailSource::ClaudeCode,
1077 None,
1078 limits,
1079 )
1080 .await
1081 .expect("third bounded pass");
1082 assert_eq!(stats3.inserted, 2);
1083 assert_eq!(stats3.new_offset, file_len, "final pass must reach EOF");
1084
1085 assert_eq!(count_rows(&rt, "session_messages").await, 6);
1089 assert_eq!(
1090 cursor_offset(&rt, &path.to_string_lossy()).await,
1091 Some(file_len as i64)
1092 );
1093
1094 let stats4 = mirror_file_with_limits(
1096 &rt,
1097 &path,
1098 stats3.new_offset,
1099 LineTailSource::ClaudeCode,
1100 None,
1101 limits,
1102 )
1103 .await
1104 .expect("fourth pass at EOF");
1105 assert_eq!(stats4.inserted, 0);
1106 assert_eq!(stats4.scanned, 0);
1107 }
1108
1109 #[tokio::test]
1110 async fn test_oversized_line_is_skipped_and_offset_advances() {
1111 let (rt, _dir) = setup().await;
1113
1114 let small1 = user_line("uuid-small1", "sess-OV", "ok");
1115 let huge_text = "x".repeat(2000);
1116 let huge = user_line("uuid-huge", "sess-OV", &huge_text);
1117 let small2 = user_line("uuid-small2", "sess-OV", "after");
1118
1119 let mut file = NamedTempFile::new().expect("tmpfile");
1120 writeln!(file, "{small1}").unwrap();
1121 writeln!(file, "{huge}").unwrap();
1122 writeln!(file, "{small2}").unwrap();
1123 let path = file.path().to_path_buf();
1124 let file_len = std::fs::metadata(&path).unwrap().len();
1125
1126 let max_line_bytes: usize = 256;
1127 assert!(
1128 huge.len() + 1 > max_line_bytes,
1129 "fixture huge line must exceed the cap"
1130 );
1131 assert!(
1132 small1.len() + 1 < max_line_bytes && small2.len() + 1 < max_line_bytes,
1133 "fixture small lines must fit under the cap"
1134 );
1135
1136 let limits = MirrorLimits {
1137 max_bytes_per_pass: MIRROR_MAX_BYTES_PER_PASS,
1138 max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
1139 max_line_bytes,
1140 };
1141
1142 let stats =
1143 mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1144 .await
1145 .expect("mirror with a small line cap");
1146
1147 assert_eq!(stats.inserted, 2, "only the two small lines are inserted");
1148 assert_eq!(
1149 stats.scanned, 2,
1150 "the oversized line must not count toward scanned"
1151 );
1152 assert_eq!(
1153 stats.new_offset, file_len,
1154 "offset must advance past the oversized line, not wedge on it"
1155 );
1156 assert_eq!(count_rows(&rt, "session_messages").await, 2);
1157 }
1158
1159 #[tokio::test]
1160 async fn test_line_just_under_cap_then_oversized_next_line_is_bounded() {
1161 let (rt, _dir) = setup().await;
1164
1165 let max_line_bytes: usize = 256;
1166 let shell_len = user_line("uuid-a", "sess-BND", "").len() + 1; let pad = max_line_bytes.saturating_sub(shell_len).saturating_sub(4);
1168 let text_a = "y".repeat(pad);
1169 let line_a = user_line("uuid-a", "sess-BND", &text_a);
1170
1171 let huge_text = "z".repeat(max_line_bytes * 4);
1172 let line_b = user_line("uuid-b", "sess-BND", &huge_text);
1173
1174 let mut file = NamedTempFile::new().expect("tmpfile");
1175 writeln!(file, "{line_a}").unwrap();
1176 writeln!(file, "{line_b}").unwrap();
1177 let path = file.path().to_path_buf();
1178 let file_len = std::fs::metadata(&path).unwrap().len();
1179
1180 assert!(
1181 line_a.len() + 1 < max_line_bytes,
1182 "fixture line A must land just under the cap"
1183 );
1184 assert!(
1185 line_b.len() + 1 > max_line_bytes,
1186 "fixture line B must land over the cap"
1187 );
1188
1189 let limits = MirrorLimits {
1190 max_bytes_per_pass: MIRROR_MAX_BYTES_PER_PASS,
1191 max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
1192 max_line_bytes,
1193 };
1194
1195 let stats =
1196 mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1197 .await
1198 .expect("mirror with a boundary line cap");
1199
1200 assert_eq!(stats.inserted, 1, "only the under-cap line is inserted");
1201 assert_eq!(
1202 stats.scanned, 1,
1203 "the oversized line must not count toward scanned"
1204 );
1205 assert_eq!(
1206 stats.new_offset, file_len,
1207 "offset must advance past both lines, including the skipped oversized one"
1208 );
1209 assert_eq!(count_rows(&rt, "session_messages").await, 1);
1210 }
1211
1212 struct CountingReader<R> {
1215 inner: R,
1216 total_read: std::rc::Rc<std::cell::Cell<usize>>,
1217 }
1218
1219 impl<R: std::io::Read> std::io::Read for CountingReader<R> {
1220 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1221 let n = self.inner.read(buf)?;
1222 self.total_read.set(self.total_read.get() + n);
1223 Ok(n)
1224 }
1225 }
1226
1227 #[test]
1228 fn test_read_line_bounded_oversized_unterminated_reads_are_capped_per_call() {
1229 let max_line_bytes: usize = 64;
1232 let buf_capacity: usize = 256;
1233 let data = vec![b'x'; 200_000];
1236
1237 let total_read = std::rc::Rc::new(std::cell::Cell::new(0));
1238 let counting = CountingReader {
1239 inner: std::io::Cursor::new(data),
1240 total_read: total_read.clone(),
1241 };
1242 let mut reader = std::io::BufReader::with_capacity(buf_capacity, counting);
1243 let mut buf = Vec::new();
1244
1245 let outcome =
1246 read_line_bounded(&mut reader, &mut buf, max_line_bytes).expect("read must not error");
1247
1248 match outcome {
1249 LineRead::OversizedUnterminated { bytes } => {
1250 assert!(
1251 bytes > max_line_bytes,
1252 "must have detected the crossing of the cap, got {bytes}"
1253 );
1254 }
1255 other => panic!("expected OversizedUnterminated, got {other:?}"),
1256 }
1257 assert!(
1258 buf.is_empty(),
1259 "buf must never buffer anything once the line is flagged oversized"
1260 );
1261
1262 let read_bytes = total_read.get();
1267 assert!(
1268 read_bytes <= max_line_bytes + buf_capacity * 4,
1269 "read_line_bounded pulled {read_bytes} bytes from the source for an \
1270 unterminated oversized line — expected at most {} (bounded to the \
1271 cap plus a few buffer refills), not an unbounded scan toward EOF",
1272 max_line_bytes + buf_capacity * 4
1273 );
1274 }
1275
1276 #[tokio::test]
1277 async fn test_oversized_unterminated_line_leaves_cursor_at_line_start_and_is_bounded_on_retry()
1278 {
1279 let (rt, _dir) = setup().await;
1282
1283 let max_line_bytes: usize = 256;
1284 let huge_unterminated = "z".repeat(max_line_bytes * 20);
1286
1287 let mut file = NamedTempFile::new().expect("tmpfile");
1288 file.write_all(huge_unterminated.as_bytes())
1289 .expect("write unterminated line");
1290 let path = file.path().to_path_buf();
1291
1292 let limits = MirrorLimits {
1293 max_bytes_per_pass: MIRROR_MAX_BYTES_PER_PASS,
1294 max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
1295 max_line_bytes,
1296 };
1297
1298 let stats1 =
1301 mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1302 .await
1303 .expect("first pass over an unterminated oversized line");
1304 assert_eq!(
1305 stats1.new_offset, 0,
1306 "cursor must stay at the line start — nothing was durably consumed"
1307 );
1308 assert_eq!(stats1.scanned, 0);
1309 assert_eq!(stats1.inserted, 0);
1310 assert_eq!(
1311 count_rows(&rt, "session_messages").await,
1312 0,
1313 "no partial/garbage row may be written for an unterminated oversized line"
1314 );
1315
1316 let stats2 = mirror_file_with_limits(
1321 &rt,
1322 &path,
1323 stats1.new_offset,
1324 LineTailSource::ClaudeCode,
1325 None,
1326 limits,
1327 )
1328 .await
1329 .expect("second pass (simulated daemon restart) over the same unterminated line");
1330 assert_eq!(stats2.new_offset, 0);
1331 assert_eq!(stats2.scanned, 0);
1332 assert_eq!(stats2.inserted, 0);
1333 assert_eq!(count_rows(&rt, "session_messages").await, 0);
1334
1335 let small_after = user_line("uuid-after-huge", "sess-UNTERM", "after");
1340 {
1341 let mut f = std::fs::OpenOptions::new()
1342 .append(true)
1343 .open(&path)
1344 .expect("reopen for append");
1345 writeln!(f).unwrap(); writeln!(f, "{small_after}").unwrap();
1347 }
1348 let file_len = std::fs::metadata(&path).unwrap().len();
1349
1350 let stats3 = mirror_file_with_limits(
1351 &rt,
1352 &path,
1353 stats2.new_offset,
1354 LineTailSource::ClaudeCode,
1355 None,
1356 limits,
1357 )
1358 .await
1359 .expect("third pass once the huge line terminates");
1360 assert_eq!(
1361 stats3.new_offset, file_len,
1362 "once terminated, the skip-and-advance path must clear past the whole \
1363 oversized line plus the following valid line"
1364 );
1365 assert_eq!(stats3.scanned, 1, "only the small trailing line is scanned");
1366 assert_eq!(stats3.inserted, 1);
1367 assert_eq!(count_rows(&rt, "session_messages").await, 1);
1368 }
1369
1370 #[tokio::test]
1371 async fn test_still_growing_partial_line_under_cap_is_unaffected() {
1372 let (rt, _dir) = setup().await;
1375
1376 let small1 = user_line("uuid-g1", "sess-GROW", "first");
1377 let mut file = NamedTempFile::new().expect("tmpfile");
1378 writeln!(file, "{small1}").unwrap();
1379 let partial_prefix = user_line("uuid-g2", "sess-GROW", "second");
1381 file.write_all(partial_prefix.as_bytes())
1382 .expect("write partial line, no trailing newline");
1383 let path = file.path().to_path_buf();
1384
1385 let limits = MirrorLimits::production();
1386
1387 let stats1 =
1388 mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1389 .await
1390 .expect("first pass: complete line + partial trailing line");
1391 assert_eq!(stats1.scanned, 1, "only the complete first line is scanned");
1392 assert_eq!(stats1.inserted, 1);
1393 assert_eq!(
1394 stats1.new_offset,
1395 (small1.len() + 1) as u64,
1396 "cursor must stop right after the first complete line, not consume the partial tail"
1397 );
1398
1399 writeln!(file).unwrap();
1401 let file_len = std::fs::metadata(&path).unwrap().len();
1402
1403 let stats2 = mirror_file_with_limits(
1404 &rt,
1405 &path,
1406 stats1.new_offset,
1407 LineTailSource::ClaudeCode,
1408 None,
1409 limits,
1410 )
1411 .await
1412 .expect("second pass: the previously-partial line now completes");
1413 assert_eq!(stats2.new_offset, file_len);
1414 assert_eq!(stats2.scanned, 1);
1415 assert_eq!(stats2.inserted, 1);
1416 assert_eq!(count_rows(&rt, "session_messages").await, 2);
1417 }
1418
1419 #[tokio::test]
1420 async fn test_large_run_of_blank_lines_is_bounded_and_persists_cursor() {
1421 let (rt, _dir) = setup().await;
1424
1425 let mut file = NamedTempFile::new().expect("tmpfile");
1426 for _ in 0..500 {
1427 writeln!(file).unwrap(); }
1429 let path = file.path().to_path_buf();
1430 let file_len = std::fs::metadata(&path).unwrap().len();
1431 assert_eq!(file_len, 500, "500 one-byte blank lines");
1432
1433 let limits = MirrorLimits {
1436 max_bytes_per_pass: 50,
1437 max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
1438 max_line_bytes: MIRROR_MAX_LINE_BYTES,
1439 };
1440
1441 let stats1 =
1442 mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1443 .await
1444 .expect("first blank-line pass");
1445
1446 assert_eq!(stats1.inserted, 0);
1447 assert_eq!(stats1.scanned, 0, "blank lines never count toward scanned");
1448 assert!(
1449 stats1.new_offset > 0,
1450 "the pass cap must trip after at least one blank line, not read unbounded"
1451 );
1452 assert!(
1453 stats1.new_offset < file_len,
1454 "a bounded pass over an all-blank file must not reach EOF in one call"
1455 );
1456
1457 let stored_offset = cursor_offset(&rt, &path.to_string_lossy()).await;
1459 assert_eq!(
1460 stored_offset,
1461 Some(stats1.new_offset as i64),
1462 "cursor must be persisted even when the pass scanned zero events"
1463 );
1464
1465 let mut offset = stats1.new_offset;
1468 loop {
1469 let stats = mirror_file_with_limits(
1470 &rt,
1471 &path,
1472 offset,
1473 LineTailSource::ClaudeCode,
1474 None,
1475 limits,
1476 )
1477 .await
1478 .expect("subsequent blank-line pass");
1479 assert_eq!(stats.inserted, 0);
1480 if stats.new_offset == offset {
1481 break; }
1483 offset = stats.new_offset;
1484 }
1485 assert_eq!(
1486 offset, file_len,
1487 "all blank lines eventually consumed to EOF"
1488 );
1489 }
1490
1491 #[tokio::test]
1492 async fn test_partial_trailing_line_not_consumed() {
1493 let (rt, _dir) = setup().await;
1494
1495 let line1 = user_line("uuid-p1", "sess-B", "Complete");
1496 let partial = r#"{"uuid":"uuid-p2","sessionId":"sess-B","type":"user""#;
1498
1499 let mut file = NamedTempFile::new().expect("tmpfile");
1500 writeln!(file, "{line1}").unwrap(); write!(file, "{partial}").unwrap(); let path = file.path().to_path_buf();
1504 let full_len = std::fs::metadata(&path).unwrap().len();
1505
1506 let stats = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1507 .await
1508 .expect("mirror_file partial");
1509
1510 assert_eq!(stats.inserted, 1, "only 1 complete line inserted");
1512 assert!(
1513 stats.new_offset < full_len,
1514 "new_offset {new} must be less than file_len {full_len}",
1515 new = stats.new_offset
1516 );
1517
1518 let stats2 = mirror_file(
1520 &rt,
1521 &path,
1522 stats.new_offset,
1523 LineTailSource::ClaudeCode,
1524 None,
1525 )
1526 .await
1527 .expect("second call");
1528 assert_eq!(
1529 stats2.inserted, 0,
1530 "partial line must not be consumed on re-poll"
1531 );
1532 assert_eq!(
1533 stats2.new_offset, stats.new_offset,
1534 "offset must not advance on partial-only content"
1535 );
1536 }
1537
1538 #[tokio::test]
1539 async fn test_duplicate_uuid_across_two_calls() {
1540 let (rt, _dir) = setup().await;
1541
1542 let line = user_line("uuid-dup", "sess-C", "First");
1543
1544 let mut file = NamedTempFile::new().expect("tmpfile");
1545 writeln!(file, "{line}").unwrap();
1546
1547 let path = file.path().to_path_buf();
1548
1549 let s1 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1551 .await
1552 .unwrap();
1553 assert_eq!(s1.inserted, 1);
1554
1555 writeln!(file, "{line}").unwrap();
1557
1558 let s2 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1560 .await
1561 .unwrap();
1562 assert_eq!(s2.inserted, 0, "duplicate uuid must not be re-inserted");
1563 assert_eq!(count_rows(&rt, "session_messages").await, 1);
1564
1565 let s3 = mirror_file(&rt, &path, s1.new_offset, LineTailSource::ClaudeCode, None)
1567 .await
1568 .unwrap();
1569 assert_eq!(s3.inserted, 0, "incremental dup must also insert 0");
1570 }
1571
1572 #[tokio::test]
1573 async fn test_replay_does_not_mutate_session_metadata() {
1574 let (rt, _dir) = setup().await;
1577
1578 let line = user_line_no_ts("uuid-nts", "sess-NTS", "no timestamp here");
1579 let mut file = NamedTempFile::new().expect("tmpfile");
1580 writeln!(file, "{line}").unwrap();
1581 let path = file.path().to_path_buf();
1582
1583 let s1 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1584 .await
1585 .unwrap();
1586 assert_eq!(s1.inserted, 1);
1587 let seen_after_first = last_seen_at(&rt, "sess-NTS")
1588 .await
1589 .expect("session row exists");
1590
1591 let s2 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1594 .await
1595 .unwrap();
1596 assert_eq!(s2.inserted, 0, "replay must insert 0 rows");
1597 let seen_after_replay = last_seen_at(&rt, "sess-NTS").await.unwrap();
1598 assert_eq!(
1599 seen_after_first, seen_after_replay,
1600 "replay must not advance last_seen_at for a timestamp-missing event"
1601 );
1602 }
1603
1604 #[tokio::test]
1605 async fn test_empty_file_is_a_no_op() {
1606 let (rt, _dir) = setup().await;
1607
1608 let file = NamedTempFile::new().expect("tmpfile");
1609 let path = file.path().to_path_buf();
1610
1611 let stats = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1612 .await
1613 .unwrap();
1614 assert_eq!(stats.inserted, 0);
1615 assert_eq!(stats.scanned, 0);
1616 assert_eq!(stats.new_offset, 0);
1617 }
1618
1619 #[tokio::test]
1620 async fn test_missing_file_returns_error() {
1621 let (rt, _dir) = setup().await;
1622 let bad_path = std::path::PathBuf::from("/nonexistent/path/session.jsonl");
1623 let result = mirror_file(&rt, &bad_path, 0, LineTailSource::ClaudeCode, None).await;
1624 assert!(
1625 matches!(result, Err(RuntimeError::Internal(_))),
1626 "missing file should return Internal error"
1627 );
1628 }
1629
1630 fn codex_message_line(role: &str, text: &str) -> String {
1636 let block_type = if role == "assistant" {
1637 "output_text"
1638 } else {
1639 "input_text"
1640 };
1641 format!(
1642 r#"{{"type":"response_item","timestamp":"2026-06-30T08:00:00Z","payload":{{"type":"message","role":"{role}","content":[{{"type":"{block_type}","text":"{text}"}}]}}}}"#
1643 )
1644 }
1645
1646 fn codex_meta_line(session_id: &str, cwd: &str, branch: &str) -> String {
1648 format!(
1649 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"}}}}}}"#
1650 )
1651 }
1652
1653 fn codex_event_msg_line() -> String {
1655 r#"{"type":"event_msg","timestamp":"2026-06-30T08:00:00Z","payload":{"type":"user_message","content":"should be skipped"}}"#.to_string()
1656 }
1657
1658 #[tokio::test]
1659 async fn test_codex_mirror_inserts_with_source_codex() {
1660 let (rt, _dir) = setup().await;
1661
1662 let session_id = "cdx-sess-0001-0001-0001-000000000001";
1663 let meta = codex_meta_line(session_id, "/home/lion/proj", "feat-x");
1664 let user_msg = codex_message_line("user", "Hello from Codex");
1665 let asst_msg = codex_message_line("assistant", "Hello back from Codex");
1666 let skip_msg = codex_event_msg_line();
1667
1668 let mut file = NamedTempFile::new().expect("tmpfile");
1669 writeln!(file, "{meta}").unwrap();
1670 writeln!(file, "{user_msg}").unwrap();
1671 writeln!(file, "{asst_msg}").unwrap();
1672 writeln!(file, "{skip_msg}").unwrap();
1673
1674 let path = file.path().to_path_buf();
1675
1676 let stats = mirror_file(&rt, &path, 0, LineTailSource::Codex, Some(session_id))
1678 .await
1679 .expect("codex mirror_file");
1680
1681 assert_eq!(stats.inserted, 3, "meta + 2 messages inserted");
1683 assert_eq!(
1684 stats.scanned, 4,
1685 "4 lines total (including skipped event_msg)"
1686 );
1687 assert!(stats.new_offset > 0);
1688
1689 let sql = rt.sql();
1691 let mut r = sql.reader().await.expect("reader");
1692 let session_row = r
1693 .query_row(SqlStatement {
1694 sql: "SELECT source FROM sessions WHERE id=?1".into(),
1695 params: vec![SqlValue::Text(session_id.to_string())],
1696 label: None,
1697 })
1698 .await
1699 .expect("query ok")
1700 .expect("session row must exist");
1701 match session_row.columns.first().map(|c| &c.value) {
1702 Some(SqlValue::Text(s)) => assert_eq!(s, "codex", "source must be 'codex'"),
1703 other => panic!("unexpected source value: {other:?}"),
1704 }
1705
1706 assert_eq!(count_rows(&rt, "session_messages").await, 3);
1708
1709 let mut r2 = sql.reader().await.expect("reader");
1713 let rows = r2
1714 .query_all(SqlStatement {
1715 sql: "SELECT role, text FROM session_messages \
1716 WHERE session_id=?1 AND role IS NOT NULL ORDER BY seq"
1717 .into(),
1718 params: vec![SqlValue::Text(session_id.to_string())],
1719 label: None,
1720 })
1721 .await
1722 .expect("query ok");
1723 let texts: Vec<(String, String)> = rows
1724 .iter()
1725 .map(|row| {
1726 let role = match row.get("role") {
1727 Some(SqlValue::Text(s)) => s.clone(),
1728 other => panic!("unexpected role value: {other:?}"),
1729 };
1730 let text = match row.get("text") {
1731 Some(SqlValue::Text(s)) => s.clone(),
1732 other => panic!("unexpected text value: {other:?}"),
1733 };
1734 (role, text)
1735 })
1736 .collect();
1737 assert_eq!(
1738 texts,
1739 vec![
1740 ("user".to_string(), "Hello from Codex".to_string()),
1741 ("assistant".to_string(), "Hello back from Codex".to_string()),
1742 ],
1743 "input_text/output_text blocks must round-trip to session_messages.text by role"
1744 );
1745 }
1746
1747 #[tokio::test]
1748 async fn test_codex_event_id_is_stable_and_idempotent() {
1749 let (rt, _dir) = setup().await;
1752
1753 let session_id = "cdx-sess-idem-0001-0001-000000000002";
1754 let user_msg = codex_message_line("user", "Idempotency test");
1755
1756 let mut file = NamedTempFile::new().expect("tmpfile");
1757 writeln!(file, "{user_msg}").unwrap();
1758
1759 let path = file.path().to_path_buf();
1760
1761 let s1 = mirror_file(&rt, &path, 0, LineTailSource::Codex, Some(session_id))
1763 .await
1764 .unwrap();
1765 assert_eq!(s1.inserted, 1);
1766
1767 let sql = rt.sql();
1769 let mut r = sql.reader().await.expect("reader");
1770 let msg_row = r
1771 .query_row(SqlStatement {
1772 sql: "SELECT id FROM session_messages WHERE session_id=?1".into(),
1773 params: vec![SqlValue::Text(session_id.to_string())],
1774 label: None,
1775 })
1776 .await
1777 .expect("query ok")
1778 .expect("message row must exist");
1779 let stored_id = match msg_row.columns.first().map(|c| &c.value) {
1780 Some(SqlValue::Text(s)) => s.clone(),
1781 other => panic!("unexpected id type: {other:?}"),
1782 };
1783 let expected_id = format!("{session_id}:0");
1785 assert_eq!(
1786 stored_id, expected_id,
1787 "synthetic uuid must be {{session_id}}:{{offset}}"
1788 );
1789
1790 let s2 = mirror_file(&rt, &path, 0, LineTailSource::Codex, Some(session_id))
1792 .await
1793 .unwrap();
1794 assert_eq!(s2.inserted, 0, "second pass must be idempotent");
1795 assert_eq!(count_rows(&rt, "session_messages").await, 1);
1796
1797 let s3 = mirror_file(
1799 &rt,
1800 &path,
1801 s1.new_offset,
1802 LineTailSource::Codex,
1803 Some(session_id),
1804 )
1805 .await
1806 .unwrap();
1807 assert_eq!(s3.inserted, 0, "incremental pass finds nothing new");
1808 }
1809
1810 #[tokio::test]
1811 async fn test_codex_and_cc_are_independent_sessions() {
1812 let (rt, _dir) = setup().await;
1814
1815 let cc_line = user_line("cc-uuid-1", "cc-sess-1", "from claude code");
1816 let mut cc_file = NamedTempFile::new().expect("cc tmpfile");
1817 writeln!(cc_file, "{cc_line}").unwrap();
1818
1819 let cdx_session_id = "cdx-sess-coex-0001-0001-000000000003";
1820 let cdx_msg = codex_message_line("user", "from codex");
1821 let mut cdx_file = NamedTempFile::new().expect("cdx tmpfile");
1822 writeln!(cdx_file, "{cdx_msg}").unwrap();
1823
1824 mirror_file(&rt, cc_file.path(), 0, LineTailSource::ClaudeCode, None)
1825 .await
1826 .unwrap();
1827
1828 mirror_file(
1829 &rt,
1830 cdx_file.path(),
1831 0,
1832 LineTailSource::Codex,
1833 Some(cdx_session_id),
1834 )
1835 .await
1836 .unwrap();
1837
1838 assert_eq!(count_rows(&rt, "sessions").await, 2);
1839 assert_eq!(count_rows(&rt, "session_messages").await, 2);
1840
1841 let sql = rt.sql();
1843 let mut r = sql.reader().await.expect("reader");
1844 let rows = r
1845 .query_all(SqlStatement {
1846 sql: "SELECT source FROM sessions ORDER BY source".into(),
1847 params: vec![],
1848 label: None,
1849 })
1850 .await
1851 .expect("query ok");
1852 let sources: Vec<String> = rows
1853 .iter()
1854 .filter_map(|row| match row.get("source") {
1855 Some(SqlValue::Text(s)) => Some(s.clone()),
1856 _ => None,
1857 })
1858 .collect();
1859 assert_eq!(sources, vec!["claude_code", "codex"]);
1860 }
1861
1862 use serde_json::json;
1870
1871 fn write_export_file(content: &str) -> (NamedTempFile, std::path::PathBuf) {
1872 let mut file = NamedTempFile::new().expect("tmpfile");
1873 write!(file, "{content}").unwrap();
1874 let path = file.path().to_path_buf();
1875 (file, path)
1876 }
1877
1878 fn chatgpt_happy_export_json() -> String {
1879 let conv = json!({
1880 "id": "conv-happy",
1881 "title": "Synthetic Happy",
1882 "create_time": 1_751_462_400.0,
1883 "current_node": "msg-happy-assistant",
1884 "mapping": {
1885 "root-happy": {
1886 "id": "root-happy",
1887 "message": null,
1888 "parent": null,
1889 "children": ["msg-happy-user"]
1890 },
1891 "msg-happy-user": {
1892 "id": "msg-happy-user",
1893 "parent": "root-happy",
1894 "children": ["msg-happy-assistant"],
1895 "message": {
1896 "id": "msg-happy-user",
1897 "author": {"role": "user"},
1898 "create_time": 1_751_462_400.0,
1899 "content": {"content_type": "text", "parts": ["Hello synthetic"]}
1900 }
1901 },
1902 "msg-happy-assistant": {
1903 "id": "msg-happy-assistant",
1904 "parent": "msg-happy-user",
1905 "children": [],
1906 "message": {
1907 "id": "msg-happy-assistant",
1908 "author": {"role": "assistant"},
1909 "create_time": 1_751_462_401.0,
1910 "content": {"content_type": "text", "parts": ["Hi synthetic"]}
1911 }
1912 }
1913 }
1914 });
1915 serde_json::to_string(&json!([conv])).unwrap()
1916 }
1917
1918 #[tokio::test]
1919 async fn test_chatgpt_happy_conversations_json() {
1920 let (rt, _dir) = setup().await;
1921 let (_file, path) = write_export_file(&chatgpt_happy_export_json());
1922 let file_len = std::fs::metadata(&path).unwrap().len();
1923
1924 let stats = mirror_chatgpt_export_file(&rt, &path, 0)
1925 .await
1926 .expect("happy path ingest");
1927 assert_eq!(stats.inserted, 2, "2 message-bearing nodes");
1928 assert_eq!(stats.scanned, 2, "2 events parsed");
1929 assert_eq!(stats.new_offset, file_len, "whole-file cursor-at-length");
1930
1931 let sql = rt.sql();
1932 let mut r = sql.reader().await.expect("reader");
1933 let row = r
1934 .query_row(SqlStatement {
1935 sql: "SELECT source, slug, cwd, git_branch FROM sessions WHERE id='conv-happy'"
1936 .into(),
1937 params: vec![],
1938 label: None,
1939 })
1940 .await
1941 .expect("query ok")
1942 .expect("session row must exist");
1943 match row.get("source") {
1944 Some(SqlValue::Text(s)) => assert_eq!(s, "chatgpt_export"),
1945 other => panic!("unexpected source: {other:?}"),
1946 }
1947 match row.get("slug") {
1948 Some(SqlValue::Text(s)) => assert_eq!(s, "Synthetic Happy"),
1949 other => panic!("unexpected slug: {other:?}"),
1950 }
1951 assert!(
1952 matches!(row.get("cwd"), Some(SqlValue::Null) | None),
1953 "chatgpt export never carries a cwd"
1954 );
1955 assert!(
1956 matches!(row.get("git_branch"), Some(SqlValue::Null) | None),
1957 "chatgpt export never carries a git branch"
1958 );
1959
1960 let mut r2 = sql.reader().await.expect("reader");
1961 let rows = r2
1962 .query_all(SqlStatement {
1963 sql: "SELECT seq, role FROM session_messages \
1964 WHERE session_id='conv-happy' ORDER BY seq"
1965 .into(),
1966 params: vec![],
1967 label: None,
1968 })
1969 .await
1970 .expect("query ok");
1971 let roles: Vec<(i64, String)> = rows
1972 .iter()
1973 .map(|row| {
1974 let seq = match row.get("seq") {
1975 Some(SqlValue::Integer(n)) => *n,
1976 other => panic!("unexpected seq: {other:?}"),
1977 };
1978 let role = match row.get("role") {
1979 Some(SqlValue::Text(s)) => s.clone(),
1980 other => panic!("unexpected role: {other:?}"),
1981 };
1982 (seq, role)
1983 })
1984 .collect();
1985 assert_eq!(
1986 roles,
1987 vec![(0, "user".to_string()), (1, "assistant".to_string())]
1988 );
1989 }
1990
1991 fn chatgpt_idempotency_export_json() -> String {
1992 let conv = json!({
1993 "id": "conv-idem",
1994 "title": "Synthetic Idempotency",
1995 "current_node": "msg-idem-assistant",
1996 "mapping": {
1997 "root-idem": {
1998 "id": "root-idem",
1999 "message": null,
2000 "parent": null,
2001 "children": ["msg-idem-user"]
2002 },
2003 "msg-idem-user": {
2004 "id": "msg-idem-user",
2005 "parent": "root-idem",
2006 "children": ["msg-idem-assistant"],
2007 "message": {
2008 "id": "msg-idem-user",
2009 "author": {"role": "user"},
2010 "content": {"content_type": "text", "parts": ["Same question again"]}
2011 }
2012 },
2013 "msg-idem-assistant": {
2014 "id": "msg-idem-assistant",
2015 "parent": "msg-idem-user",
2016 "children": [],
2017 "message": {
2018 "id": "msg-idem-assistant",
2019 "author": {"role": "assistant"},
2020 "content": {"content_type": "text", "parts": ["Same answer again"]}
2021 }
2022 }
2023 }
2024 });
2025 serde_json::to_string(&json!([conv])).unwrap()
2026 }
2027
2028 #[tokio::test]
2029 async fn test_chatgpt_reingest_idempotency_conversations_json() {
2030 let (rt, _dir) = setup().await;
2031 let (_file, path) = write_export_file(&chatgpt_idempotency_export_json());
2032
2033 let s1 = mirror_chatgpt_export_file(&rt, &path, 0)
2034 .await
2035 .expect("first ingest");
2036 assert_eq!(s1.inserted, 2);
2037
2038 let seen_after_first = last_seen_at(&rt, "conv-idem")
2039 .await
2040 .expect("session row exists");
2041
2042 let s2 = mirror_chatgpt_export_file(&rt, &path, 0)
2045 .await
2046 .expect("second ingest");
2047 assert_eq!(s2.inserted, 0, "re-ingest must insert 0 new rows");
2048
2049 let sql = rt.sql();
2050 let mut r = sql.reader().await.expect("reader");
2051 let count = r
2052 .query_row(SqlStatement {
2053 sql: "SELECT COUNT(*) FROM session_messages WHERE session_id='conv-idem'".into(),
2054 params: vec![],
2055 label: None,
2056 })
2057 .await
2058 .expect("query ok")
2059 .expect("count row");
2060 match count.columns.first().map(|c| &c.value) {
2061 Some(SqlValue::Integer(n)) => assert_eq!(*n, 2, "message count stays at 2"),
2062 other => panic!("unexpected count: {other:?}"),
2063 }
2064
2065 let seen_after_replay = last_seen_at(&rt, "conv-idem")
2066 .await
2067 .expect("session row still exists");
2068 assert_eq!(
2069 seen_after_first, seen_after_replay,
2070 "pure replay must not advance last_seen_at"
2071 );
2072 }
2073
2074 fn chatgpt_branch_sidechain_export_json() -> String {
2075 let conv = json!({
2076 "id": "conv-branch",
2077 "title": "Synthetic Branch",
2078 "current_node": "msg-branch-main",
2079 "mapping": {
2080 "root-branch": {
2081 "id": "root-branch",
2082 "message": null,
2083 "parent": null,
2084 "children": ["msg-branch-user"]
2085 },
2086 "msg-branch-user": {
2087 "id": "msg-branch-user",
2088 "parent": "root-branch",
2089 "children": ["msg-branch-main", "msg-branch-alt"],
2090 "message": {
2091 "id": "msg-branch-user",
2092 "author": {"role": "user"},
2093 "content": {"content_type": "text", "parts": ["Branch question"]}
2094 }
2095 },
2096 "msg-branch-main": {
2097 "id": "msg-branch-main",
2098 "parent": "msg-branch-user",
2099 "children": [],
2100 "message": {
2101 "id": "msg-branch-main",
2102 "author": {"role": "assistant"},
2103 "content": {"content_type": "text", "parts": ["Main answer"]}
2104 }
2105 },
2106 "msg-branch-alt": {
2107 "id": "msg-branch-alt",
2108 "parent": "msg-branch-user",
2109 "children": [],
2110 "message": {
2111 "id": "msg-branch-alt",
2112 "author": {"role": "assistant"},
2113 "content": {"content_type": "text", "parts": ["Alternate answer"]}
2114 }
2115 }
2116 }
2117 });
2118 serde_json::to_string(&json!([conv])).unwrap()
2119 }
2120
2121 #[tokio::test]
2122 async fn test_chatgpt_branch_sidechain_conversations_json() {
2123 let (rt, _dir) = setup().await;
2124 let (_file, path) = write_export_file(&chatgpt_branch_sidechain_export_json());
2125
2126 let stats = mirror_chatgpt_export_file(&rt, &path, 0)
2127 .await
2128 .expect("branch ingest");
2129 assert_eq!(stats.inserted, 3, "user + main + alt all stored");
2130
2131 let sql = rt.sql();
2132 let mut r = sql.reader().await.expect("reader");
2133 let rows = r
2134 .query_all(SqlStatement {
2135 sql: "SELECT id, is_sidechain, text FROM session_messages \
2136 WHERE session_id='conv-branch' ORDER BY id"
2137 .into(),
2138 params: vec![],
2139 label: None,
2140 })
2141 .await
2142 .expect("query ok");
2143 assert_eq!(rows.len(), 3);
2144
2145 for row in &rows {
2146 let id = match row.get("id") {
2147 Some(SqlValue::Text(s)) => s.clone(),
2148 other => panic!("unexpected id: {other:?}"),
2149 };
2150 let is_sidechain = match row.get("is_sidechain") {
2151 Some(SqlValue::Integer(n)) => *n,
2152 other => panic!("unexpected is_sidechain: {other:?}"),
2153 };
2154 let text = match row.get("text") {
2155 Some(SqlValue::Text(s)) => s.clone(),
2156 other => panic!("unexpected text: {other:?}"),
2157 };
2158 match id.as_str() {
2159 "msg-branch-user" | "msg-branch-main" => {
2160 assert_eq!(is_sidechain, 0, "{id} is on the current-node path")
2161 }
2162 "msg-branch-alt" => {
2163 assert_eq!(is_sidechain, 1, "alt branch is off the current-node path");
2164 assert_eq!(
2165 text, "Alternate answer",
2166 "sidechain content must be preserved, not dropped"
2167 );
2168 }
2169 other => panic!("unexpected message id: {other}"),
2170 }
2171 }
2172 }
2173
2174 #[tokio::test]
2175 async fn test_chatgpt_malformed_conversations_json_cursor_does_not_advance() {
2176 let (rt, _dir) = setup().await;
2177
2178 let (mut file, path) = write_export_file("[]");
2180 let seeded_stats = mirror_chatgpt_export_file(&rt, &path, 0)
2181 .await
2182 .expect("seeding with an empty array is a valid parse");
2183 assert_eq!(seeded_stats.inserted, 0);
2184 let seeded_offset = seeded_stats.new_offset;
2185
2186 let seeded_sessions = count_rows(&rt, "sessions").await;
2187 let seeded_messages = count_rows(&rt, "session_messages").await;
2188
2189 let malformed = r#"{"oops": "not a chatgpt export array"}"#;
2191 file.as_file_mut().set_len(0).expect("truncate");
2192 std::io::Seek::seek(file.as_file_mut(), std::io::SeekFrom::Start(0)).unwrap();
2193 write!(file, "{malformed}").unwrap();
2194
2195 let result = mirror_chatgpt_export_file(&rt, &path, seeded_offset).await;
2196 assert!(
2197 matches!(result, Err(RuntimeError::Internal(_))),
2198 "malformed export must return Internal error, got {result:?}"
2199 );
2200
2201 let stored_offset = cursor_offset(&rt, &path.to_string_lossy()).await;
2202 assert_eq!(
2203 stored_offset,
2204 Some(seeded_offset as i64),
2205 "cursor must remain at the pre-error value"
2206 );
2207 assert_eq!(
2208 count_rows(&rt, "sessions").await,
2209 seeded_sessions,
2210 "no new session rows on parse failure"
2211 );
2212 assert_eq!(
2213 count_rows(&rt, "session_messages").await,
2214 seeded_messages,
2215 "no new message rows on parse failure"
2216 );
2217 }
2218
2219 #[tokio::test]
2220 async fn test_chatgpt_export_over_max_bytes_is_skipped_without_reading() {
2221 let (rt, _dir) = setup().await;
2224 let (_file, path) = write_export_file("[]");
2225
2226 let file_len = std::fs::metadata(&path).unwrap().len();
2227 let max_bytes = 1u64; assert!(
2229 file_len > max_bytes,
2230 "fixture export must exceed the tiny ceiling"
2231 );
2232
2233 let stats = mirror_chatgpt_export_file_with_max_bytes(&rt, &path, 0, max_bytes)
2234 .await
2235 .expect("an oversized export must be skipped, not error");
2236
2237 assert_eq!(stats.inserted, 0);
2238 assert_eq!(stats.scanned, 0);
2239 assert_eq!(
2240 stats.new_offset, 0,
2241 "cursor must not advance past a skipped oversized export"
2242 );
2243 assert_eq!(
2244 cursor_offset(&rt, &path.to_string_lossy()).await,
2245 None,
2246 "no cursor row should be written for a skipped pass"
2247 );
2248 assert_eq!(count_rows(&rt, "sessions").await, 0);
2249 assert_eq!(count_rows(&rt, "session_messages").await, 0);
2250 }
2251
2252 #[tokio::test]
2253 async fn test_chatgpt_secret_bearing_conversations_json_is_masked() {
2254 let secret_fragment_a = "AKIA";
2258 let secret_fragment_b = "FAKEKEY1234567890";
2259 let secret = format!("{secret_fragment_a}{secret_fragment_b}");
2260 let user_text = format!("here is my key: {secret}");
2261
2262 let conv = json!({
2263 "id": "conv-secret",
2264 "title": "Synthetic Secret",
2265 "current_node": "msg-secret-user",
2266 "mapping": {
2267 "root-secret": {
2268 "id": "root-secret",
2269 "message": null,
2270 "parent": null,
2271 "children": ["msg-secret-user"]
2272 },
2273 "msg-secret-user": {
2274 "id": "msg-secret-user",
2275 "parent": "root-secret",
2276 "children": [],
2277 "message": {
2278 "id": "msg-secret-user",
2279 "author": {"role": "user"},
2280 "content": {"content_type": "text", "parts": [user_text]}
2281 }
2282 }
2283 }
2284 });
2285 let content = serde_json::to_string(&json!([conv])).unwrap();
2286 let (_file, path) = write_export_file(&content);
2287
2288 let (rt, _dir) = setup().await;
2289 let stats = mirror_chatgpt_export_file(&rt, &path, 0)
2290 .await
2291 .expect("secret-bearing content must still ingest, only masked");
2292 assert_eq!(stats.inserted, 1);
2293
2294 let sql = rt.sql();
2295 let mut r = sql.reader().await.expect("reader");
2296 let row = r
2297 .query_row(SqlStatement {
2298 sql: "SELECT text, raw FROM session_messages WHERE session_id='conv-secret'".into(),
2299 params: vec![],
2300 label: None,
2301 })
2302 .await
2303 .expect("query ok")
2304 .expect("message row must exist");
2305 let (stored_text, stored_raw) = match (row.get("text"), row.get("raw")) {
2306 (Some(SqlValue::Text(t)), Some(SqlValue::Text(r))) => (t.clone(), r.clone()),
2307 other => panic!("unexpected text/raw shape: {other:?}"),
2308 };
2309
2310 assert!(
2311 !stored_text.contains(&secret),
2312 "stored text must not contain the raw secret"
2313 );
2314 assert!(
2315 !stored_raw.contains(&secret),
2316 "stored raw must not contain the raw secret"
2317 );
2318 assert!(
2319 stored_text.contains("***MASKED***"),
2320 "stored text must carry the secret_gate redaction marker"
2321 );
2322 assert!(
2323 stored_raw.contains("***MASKED***"),
2324 "stored raw must carry the secret_gate redaction marker"
2325 );
2326 }
2327
2328 #[tokio::test]
2333 async fn test_mid_transaction_db_error_leaves_no_partial_state_and_cursor_unadvanced() {
2334 let (rt, _dir) = setup().await;
2335 let sql = rt.sql();
2336 let path = std::path::Path::new("/synthetic/mid-tx-probe.json");
2337 let path_owned = path.to_path_buf();
2338
2339 let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
2340 Box::pin(async move {
2341 writer
2344 .execute(SqlStatement {
2345 sql: "INSERT INTO sessions \
2346 (id, provider_session_id, source, message_count, first_seen_at, last_seen_at, namespace) \
2347 VALUES('mid-tx-session', 'mid-tx-session', 'chatgpt_export', 0, 1, 1, 'local')"
2348 .into(),
2349 params: vec![],
2350 label: None,
2351 })
2352 .await?;
2353
2354 upsert_cursor_on_writer(writer, &path_owned, Some("mid-tx-session"), 999, 1)
2357 .await?;
2358
2359 writer
2362 .execute(SqlStatement {
2363 sql: "INSERT INTO no_such_table_mid_tx_probe(a) VALUES(1)".into(),
2364 params: vec![],
2365 label: None,
2366 })
2367 .await?;
2368
2369 Ok(Box::new(()) as Box<dyn std::any::Any + Send>)
2370 })
2371 });
2372
2373 let result = sql.atomic_unit(op).await;
2377 assert!(
2378 result.is_err(),
2379 "atomic_unit must propagate the forced third-write failure"
2380 );
2381
2382 assert_eq!(
2383 count_rows(&rt, "sessions").await,
2384 0,
2385 "session write must not survive a later error in the same atomic unit"
2386 );
2387 assert_eq!(
2388 cursor_offset(&rt, &path.to_string_lossy()).await,
2389 None,
2390 "cursor must not advance when a later write in the same atomic unit fails"
2391 );
2392 }
2393
2394 fn write_queue_pool(db_path: std::path::PathBuf) -> Arc<khive_db::ConnectionPool> {
2398 let pool_cfg = khive_db::PoolConfig {
2399 path: Some(db_path),
2400 write_queue_enabled: true,
2401 ..khive_db::PoolConfig::default()
2402 };
2403 let pool = Arc::new(khive_db::ConnectionPool::new(pool_cfg).expect("pool"));
2404 {
2405 let w_conn = pool.writer().expect("writer");
2406 for stmt in &SESSION_SCHEMA_PLAN_STMTS {
2407 w_conn
2408 .conn()
2409 .execute_batch(stmt)
2410 .expect("session schema stmt");
2411 }
2412 }
2413 pool
2414 }
2415
2416 #[tokio::test]
2421 async fn write_events_and_cursor_is_suspension_free_under_single_writer() {
2422 let dir = TempDir::new().expect("tempdir");
2423 let pool = write_queue_pool(dir.path().join("suspend_free.db"));
2424 let sql: Arc<dyn khive_storage::SqlAccess> =
2425 Arc::new(khive_db::SqlBridge::new(Arc::clone(&pool), true));
2426
2427 pool.writer_task_handle()
2428 .unwrap()
2429 .expect("writer task must be spawned with the flag on for a file-backed pool");
2430
2431 let events = vec![parse::parse_cc_line(
2432 r#"{"uuid":"evt-1","sessionId":"suspend-free-session","type":"user","message":{"role":"user","content":"hello"},"cwd":"/tmp","timestamp":"2026-01-01T00:00:00Z"}"#,
2433 )
2434 .expect("line must parse")];
2435
2436 let path = std::path::Path::new("/synthetic/suspend-free.jsonl").to_path_buf();
2437 let now_us = Utc::now().timestamp_micros();
2438 let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
2439 Box::pin(async move {
2440 write_events_and_cursor_on_writer(
2441 writer,
2442 &path,
2443 "claude_code",
2444 &events,
2445 1,
2446 100,
2447 now_us,
2448 )
2449 .await
2450 .map(|stats| Box::new(stats) as Box<dyn std::any::Any + Send>)
2451 .map_err(|e| {
2452 khive_storage::StorageError::driver(
2453 khive_storage::StorageCapability::Sql,
2454 "test_write_events_and_cursor",
2455 e,
2456 )
2457 })
2458 })
2459 });
2460
2461 let boxed = sql
2462 .atomic_unit(op)
2463 .await
2464 .expect("a suspension-free closure must not hit block_on_sync's Pending error");
2465 let stats = *boxed
2466 .downcast::<MirrorStats>()
2467 .expect("op must return MirrorStats");
2468
2469 assert_eq!(stats.inserted, 1, "the one event must be inserted");
2470
2471 let mut reader = sql.reader().await.expect("reader");
2472 let row = khive_storage::SqlReader::query_scalar(
2473 reader.as_mut(),
2474 SqlStatement {
2475 sql: "SELECT COUNT(*) FROM sessions".into(),
2476 params: vec![],
2477 label: None,
2478 },
2479 )
2480 .await
2481 .expect("count query")
2482 .expect("count row");
2483 match row {
2484 SqlValue::Integer(1) => {}
2485 other => panic!("the session row must be committed, got COUNT(*) = {other:?}"),
2486 }
2487 }
2488
2489 #[tokio::test]
2494 async fn session_ingest_routes_through_writer_task_when_flag_enabled() {
2495 let dir = TempDir::new().expect("tempdir");
2496 let pool = write_queue_pool(dir.path().join("concurrency.db"));
2497 let sql: Arc<dyn khive_storage::SqlAccess> =
2498 Arc::new(khive_db::SqlBridge::new(Arc::clone(&pool), true));
2499
2500 let writer_task = pool
2501 .writer_task_handle()
2502 .unwrap()
2503 .expect("writer task must be spawned with the flag on for a file-backed pool");
2504
2505 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
2506 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
2507 let occupier = {
2508 let writer_task = writer_task.clone();
2509 tokio::spawn(async move {
2510 writer_task
2511 .send(move |_conn| {
2512 let _ = started_tx.send(());
2513 let _ = release_rx.blocking_recv();
2514 Ok::<(), khive_storage::StorageError>(())
2515 })
2516 .await
2517 })
2518 };
2519
2520 started_rx
2521 .await
2522 .expect("occupier must signal it has started running inside the writer task");
2523 assert_eq!(
2524 writer_task.queue_depth(),
2525 0,
2526 "channel must start empty once the occupier has been dequeued and is running"
2527 );
2528
2529 let events = vec![parse::parse_cc_line(
2530 r#"{"uuid":"evt-concurrency-1","sessionId":"concurrency-session","type":"user","message":{"role":"user","content":"hello"},"cwd":"/tmp","timestamp":"2026-01-01T00:00:00Z"}"#,
2531 )
2532 .expect("line must parse")];
2533 let path = std::path::Path::new("/synthetic/concurrency.jsonl").to_path_buf();
2534 let now_us = Utc::now().timestamp_micros();
2535 let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
2536 Box::pin(async move {
2537 write_events_and_cursor_on_writer(
2538 writer,
2539 &path,
2540 "claude_code",
2541 &events,
2542 1,
2543 100,
2544 now_us,
2545 )
2546 .await
2547 .map(|stats| Box::new(stats) as Box<dyn std::any::Any + Send>)
2548 .map_err(|e| {
2549 khive_storage::StorageError::driver(
2550 khive_storage::StorageCapability::Sql,
2551 "test_session_ingest_concurrency",
2552 e,
2553 )
2554 })
2555 })
2556 });
2557
2558 let sql_for_ingest = Arc::clone(&sql);
2559 let ingest_task = tokio::spawn(async move { sql_for_ingest.atomic_unit(op).await });
2560
2561 let mut saw_enqueued = false;
2562 for _ in 0..100 {
2563 if writer_task.queue_depth() >= 1 {
2564 saw_enqueued = true;
2565 break;
2566 }
2567 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2568 }
2569 assert!(
2570 saw_enqueued,
2571 "session-ingest's atomic_unit request never appeared in the writer task's \
2572 channel while the occupier held the single drain slot — the converted ingest \
2573 path is not routing through the shared writer task (a standalone `begin_tx` \
2574 connection would never show up here at all)"
2575 );
2576
2577 release_tx
2578 .send(())
2579 .expect("occupier must still be waiting on the release signal");
2580 occupier
2581 .await
2582 .expect("occupier task must not panic")
2583 .expect("occupier write must succeed");
2584
2585 let boxed = ingest_task
2586 .await
2587 .expect("ingest task must not panic")
2588 .expect("ingest atomic_unit must succeed once the occupier releases the slot");
2589 let stats = *boxed
2590 .downcast::<MirrorStats>()
2591 .expect("op must return MirrorStats");
2592 assert_eq!(stats.inserted, 1, "the ingest event must be committed");
2593 }
2594
2595 #[tokio::test]
2600 async fn old_shape_manual_begin_immediate_inside_atomic_unit_fails() {
2601 let dir = TempDir::new().expect("tempdir");
2602 let pool = write_queue_pool(dir.path().join("old_shape_begin_immediate.db"));
2603 let sql: Arc<dyn khive_storage::SqlAccess> =
2604 Arc::new(khive_db::SqlBridge::new(Arc::clone(&pool), true));
2605
2606 pool.writer_task_handle()
2607 .unwrap()
2608 .expect("writer task must be spawned with the flag on for a file-backed pool");
2609
2610 let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
2611 Box::pin(async move {
2612 writer
2618 .execute(SqlStatement {
2619 sql: "BEGIN IMMEDIATE".into(),
2620 params: vec![],
2621 label: None,
2622 })
2623 .await?;
2624 Ok(Box::new(()) as Box<dyn std::any::Any + Send>)
2625 })
2626 });
2627
2628 let err = sql.atomic_unit(op).await.expect_err(
2629 "a closure that issues its own BEGIN IMMEDIATE inside atomic_unit must fail with a \
2630 nested-transaction error, not silently succeed",
2631 );
2632 let msg = err.to_string();
2633 assert!(
2634 msg.contains("cannot start a transaction within a transaction"),
2635 "expected the deterministic nested-transaction failure (SQLite's own message for a \
2636 second BEGIN issued inside an already-open transaction), got: {msg}"
2637 );
2638 }
2639}