Skip to main content

khive_pack_session/mirror/
ingest.rs

1//! Idempotent file tail + upsert into the session mirror tables.
2//!
3//! `mirror_file` reads new bytes from a JSONL file starting at `start_offset`,
4//! parses complete lines using the parser selected by `MirrorSource`, and writes
5//! them to the session mirror tables in a single transaction.  It is safe to call
6//! repeatedly on the same file; `INSERT OR IGNORE` keyed by the event UUID ensures
7//! idempotency.
8
9use std::io::{Read, Seek, SeekFrom};
10use std::path::Path;
11
12use chrono::Utc;
13use khive_runtime::{KhiveRuntime, RuntimeError};
14use khive_storage::types::{SqlStatement, SqlTxOptions, SqlValue};
15
16use super::parse;
17
18/// Identifies which CLI produced the JSONL file being mirrored.
19///
20/// The value is written to `sessions.source` and selects the line parser.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum MirrorSource {
23    /// Claude Code (`~/.claude/projects/<slug>/<uuid>.jsonl`).
24    ClaudeCode,
25    /// Codex CLI (`~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`).
26    Codex,
27}
28
29impl MirrorSource {
30    /// The string written to `sessions.source`.
31    pub fn as_str(self) -> &'static str {
32        match self {
33            MirrorSource::ClaudeCode => "claude_code",
34            MirrorSource::Codex => "codex",
35        }
36    }
37}
38
39/// Statistics returned by a single `mirror_file` call.
40#[derive(Debug, Default, Clone, PartialEq)]
41pub struct MirrorStats {
42    /// Number of new message rows inserted (0 if all were already present).
43    pub inserted: u64,
44    /// Number of complete lines scanned (including duplicates).
45    pub scanned: u64,
46    /// Byte offset advanced to (only past complete lines; partial trailing line excluded).
47    pub new_offset: u64,
48}
49
50/// Read new bytes of `path` starting at `start_offset`, parse complete lines
51/// using the parser selected by `source`, and upsert them idempotently into the
52/// session mirror tables.
53///
54/// For `MirrorSource::Codex`, `codex_session_id` must be the session UUID
55/// derived from the filename; it is used both to key the `sessions` row and to
56/// synthesise per-line event UUIDs (`"{session_id}:{abs_byte_offset}"`).
57/// For `MirrorSource::ClaudeCode`, `codex_session_id` is ignored (the session
58/// UUID is embedded in each line).
59///
60/// Returns stats including the advanced byte offset.  A partial trailing line
61/// (no terminating `\n`) is left for the next poll — `new_offset` is set to
62/// the byte after the last complete `\n`.
63///
64/// One bad file or one bad line does NOT kill the loop: per-file errors propagate
65/// to the caller (the service loop logs and continues); per-line parse failures
66/// are silently skipped (the parser returns `None`).
67pub async fn mirror_file(
68    runtime: &KhiveRuntime,
69    path: &Path,
70    start_offset: u64,
71    source: MirrorSource,
72    codex_session_id: Option<&str>,
73) -> Result<MirrorStats, RuntimeError> {
74    // ── read new bytes ────────────────────────────────────────────────────────
75
76    let content = read_from_offset(path, start_offset).map_err(|e| {
77        RuntimeError::Internal(format!(
78            "mirror_file: failed to read {:?} at offset {start_offset}: {e}",
79            path
80        ))
81    })?;
82
83    if content.is_empty() {
84        return Ok(MirrorStats {
85            inserted: 0,
86            scanned: 0,
87            new_offset: start_offset,
88        });
89    }
90
91    // ── find last complete line (ends in '\n') ────────────────────────────────
92
93    let last_newline = content
94        .iter()
95        .enumerate()
96        .rev()
97        .find(|(_, &b)| b == b'\n')
98        .map(|(i, _)| i);
99
100    let (complete_bytes, partial_len) = match last_newline {
101        Some(pos) => (pos + 1, content.len() - pos - 1),
102        None => {
103            // All bytes form a partial line — nothing to consume.
104            return Ok(MirrorStats {
105                inserted: 0,
106                scanned: 0,
107                new_offset: start_offset,
108            });
109        }
110    };
111
112    let new_offset = start_offset + complete_bytes as u64;
113    let _ = partial_len; // not needed beyond the offset calculation above
114
115    // ── parse complete lines ──────────────────────────────────────────────────
116
117    let complete_content = String::from_utf8_lossy(&content[..complete_bytes]);
118
119    // For Codex lines we need the absolute byte offset of each line start so
120    // the synthesised event UUID is stable across re-tails.  Compute line
121    // boundaries once, then iterate with offsets.
122    let events: Vec<parse::ParsedEvent> = match source {
123        MirrorSource::ClaudeCode => complete_content
124            .split('\n')
125            .filter(|l| !l.is_empty())
126            .filter_map(parse::parse_cc_line)
127            .collect(),
128        MirrorSource::Codex => {
129            let sid = codex_session_id.unwrap_or("");
130            let mut line_start: u64 = start_offset;
131            let mut evs = Vec::new();
132            for raw_line in complete_content.split('\n') {
133                let line_byte_len = raw_line.len() as u64 + 1; // +1 for the '\n'
134                if !raw_line.is_empty() {
135                    if let Some(ev) = parse::parse_codex_line(raw_line, sid, line_start) {
136                        evs.push(ev);
137                    }
138                }
139                line_start += line_byte_len;
140            }
141            evs
142        }
143    };
144
145    let scanned = complete_content
146        .split('\n')
147        .filter(|l| !l.is_empty())
148        .count() as u64;
149
150    if events.is_empty() {
151        // Apply cursor update even when there are no parseable events so we
152        // don't re-read the same bytes on the next tick.
153        let _ = write_cursor_only(runtime, path, &None, new_offset).await;
154        return Ok(MirrorStats {
155            inserted: 0,
156            scanned,
157            new_offset,
158        });
159    }
160
161    // ── write in one transaction ──────────────────────────────────────────────
162
163    let now_us = Utc::now().timestamp_micros();
164    let sql = runtime.sql();
165
166    let mut tx = sql
167        .begin_tx(SqlTxOptions::default())
168        .await
169        .map_err(|e| RuntimeError::Internal(format!("mirror_file: begin_tx: {e}")))?;
170
171    let mut inserted: u64 = 0;
172    let mut last_session_id: Option<String> = None;
173
174    for ev in &events {
175        let created_at = if ev.created_at_micros != 0 {
176            ev.created_at_micros
177        } else {
178            now_us
179        };
180
181        // ── sessions row: create-only ─────────────────────────────────────────
182        //
183        // First sight of a session creates the row (first_seen_at = last_seen_at =
184        // this event's timestamp). Replays are a cheap no-op (`DO NOTHING`), so a
185        // pass that inserts no new messages writes no session metadata at all —
186        // strict replay idempotency. `last_seen_at` is advanced below, but only
187        // when a genuinely new message lands.
188        tx.execute(SqlStatement {
189            sql: format!(
190                "INSERT INTO sessions \
191                  (id, provider_session_id, source, cwd, git_branch, slug, \
192                   message_count, first_seen_at, last_seen_at, namespace) \
193                  VALUES(?1, ?1, '{}', ?2, ?3, ?4, 0, ?5, ?5, 'local') \
194                  ON CONFLICT(id) DO NOTHING",
195                source.as_str()
196            ),
197            params: vec![
198                SqlValue::Text(ev.session_id.clone()),
199                ev.cwd
200                    .as_deref()
201                    .map(|s| SqlValue::Text(s.to_string()))
202                    .unwrap_or(SqlValue::Null),
203                ev.git_branch
204                    .as_deref()
205                    .map(|s| SqlValue::Text(s.to_string()))
206                    .unwrap_or(SqlValue::Null),
207                ev.slug
208                    .as_deref()
209                    .map(|s| SqlValue::Text(s.to_string()))
210                    .unwrap_or(SqlValue::Null),
211                SqlValue::Integer(created_at),
212            ],
213            label: Some("session_mirror_create_session".into()),
214        })
215        .await
216        .map_err(|e| RuntimeError::Internal(format!("mirror_file: session create: {e}")))?;
217
218        // ── session_messages insert (idempotent) ──────────────────────────────
219        let affected = tx
220            .execute(SqlStatement {
221                sql: "INSERT OR IGNORE INTO session_messages \
222                      (id, session_id, seq, parent_uuid, is_sidechain, role, \
223                       msg_type, text, raw, created_at, namespace) \
224                      VALUES(?1, ?2, \
225                        (SELECT COALESCE(MAX(seq),-1)+1 FROM session_messages WHERE session_id=?2), \
226                        ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'local')"
227                    .into(),
228                params: vec![
229                    SqlValue::Text(ev.uuid.clone()),
230                    SqlValue::Text(ev.session_id.clone()),
231                    ev.parent_uuid
232                        .as_deref()
233                        .map(|s| SqlValue::Text(s.to_string()))
234                        .unwrap_or(SqlValue::Null),
235                    SqlValue::Integer(i64::from(ev.is_sidechain)),
236                    ev.role
237                        .as_deref()
238                        .map(|s| SqlValue::Text(s.to_string()))
239                        .unwrap_or(SqlValue::Null),
240                    SqlValue::Text(ev.msg_type.clone()),
241                    ev.text
242                        .as_deref()
243                        .map(|s| SqlValue::Text(s.to_string()))
244                        .unwrap_or(SqlValue::Null),
245                    SqlValue::Text(ev.raw.clone()),
246                    SqlValue::Integer(created_at),
247                ],
248                label: Some("session_mirror_insert_message".into()),
249            })
250            .await
251            .map_err(|e| RuntimeError::Internal(format!("mirror_file: message insert: {e}")))?;
252
253        // ── advance session metadata ONLY when a new message landed ────────────
254        //
255        // Keeps `last_seen_at` monotonic (`MAX`) so a timestamp-missing replay
256        // (whose `created_at` fell back to `now_us`) cannot move it forward, and
257        // backfills metadata that may have been NULL at create time. A pure
258        // replay (`affected == 0`) touches nothing.
259        if affected > 0 {
260            tx.execute(SqlStatement {
261                sql: "UPDATE sessions SET \
262                        last_seen_at=MAX(last_seen_at, ?2), \
263                        cwd=COALESCE(cwd, ?3), \
264                        git_branch=COALESCE(git_branch, ?4), \
265                        slug=COALESCE(slug, ?5) \
266                      WHERE id=?1"
267                    .into(),
268                params: vec![
269                    SqlValue::Text(ev.session_id.clone()),
270                    SqlValue::Integer(created_at),
271                    ev.cwd
272                        .as_deref()
273                        .map(|s| SqlValue::Text(s.to_string()))
274                        .unwrap_or(SqlValue::Null),
275                    ev.git_branch
276                        .as_deref()
277                        .map(|s| SqlValue::Text(s.to_string()))
278                        .unwrap_or(SqlValue::Null),
279                    ev.slug
280                        .as_deref()
281                        .map(|s| SqlValue::Text(s.to_string()))
282                        .unwrap_or(SqlValue::Null),
283                ],
284                label: Some("session_mirror_touch_session".into()),
285            })
286            .await
287            .map_err(|e| RuntimeError::Internal(format!("mirror_file: session touch: {e}")))?;
288        }
289
290        inserted += affected;
291        last_session_id = Some(ev.session_id.clone());
292    }
293
294    // ── refresh message_count for each distinct session ───────────────────────
295    //
296    // In practice one JSONL file maps to one session_id, but we refresh every
297    // session_id we touched to stay correct even if that changes. Skipped
298    // entirely on a pure replay (`inserted == 0`): the counts cannot have
299    // changed, so writing them would be needless churn.
300    if inserted > 0 {
301        let mut seen_sessions: Vec<String> = events
302            .iter()
303            .map(|e| e.session_id.clone())
304            .collect::<std::collections::HashSet<_>>()
305            .into_iter()
306            .collect();
307        seen_sessions.sort(); // deterministic order for tests
308
309        for sid in &seen_sessions {
310            tx.execute(SqlStatement {
311                sql: "UPDATE sessions SET message_count=\
312                      (SELECT COUNT(*) FROM session_messages WHERE session_id=?1) \
313                      WHERE id=?1"
314                    .into(),
315                params: vec![SqlValue::Text(sid.clone())],
316                label: Some("session_mirror_refresh_count".into()),
317            })
318            .await
319            .map_err(|e| RuntimeError::Internal(format!("mirror_file: count refresh: {e}")))?;
320        }
321    }
322
323    // ── cursor upsert ─────────────────────────────────────────────────────────
324    let path_str = path.to_string_lossy().into_owned();
325    tx.execute(SqlStatement {
326        sql: "INSERT INTO session_mirror_cursor(file_path, session_id, byte_offset, updated_at) \
327              VALUES(?1, ?2, ?3, ?4) \
328              ON CONFLICT(file_path) DO UPDATE SET \
329                session_id=excluded.session_id, \
330                byte_offset=excluded.byte_offset, \
331                updated_at=excluded.updated_at"
332            .into(),
333        params: vec![
334            SqlValue::Text(path_str),
335            last_session_id
336                .as_deref()
337                .map(|s| SqlValue::Text(s.to_string()))
338                .unwrap_or(SqlValue::Null),
339            SqlValue::Integer(new_offset as i64),
340            SqlValue::Integer(now_us),
341        ],
342        label: Some("session_mirror_cursor_upsert".into()),
343    })
344    .await
345    .map_err(|e| RuntimeError::Internal(format!("mirror_file: cursor upsert: {e}")))?;
346
347    // ── commit ────────────────────────────────────────────────────────────────
348    tx.commit()
349        .await
350        .map_err(|e| RuntimeError::Internal(format!("mirror_file: commit: {e}")))?;
351
352    Ok(MirrorStats {
353        inserted,
354        scanned,
355        new_offset,
356    })
357}
358
359/// Read bytes from `path` starting at `offset` to EOF.
360///
361/// Returns an empty Vec when `offset` is at or past EOF.
362fn read_from_offset(path: &Path, offset: u64) -> std::io::Result<Vec<u8>> {
363    let mut file = std::fs::File::open(path)?;
364    let file_len = file.metadata()?.len();
365    if offset >= file_len {
366        return Ok(Vec::new());
367    }
368    file.seek(SeekFrom::Start(offset))?;
369    let capacity = (file_len - offset) as usize;
370    let mut buf = Vec::with_capacity(capacity);
371    file.read_to_end(&mut buf)?;
372    Ok(buf)
373}
374
375/// Write only the cursor row (no events).  Used when there are no parseable
376/// events so the offset still advances past blank/unparseable content.
377async fn write_cursor_only(
378    runtime: &KhiveRuntime,
379    path: &Path,
380    session_id: &Option<String>,
381    new_offset: u64,
382) -> Result<(), RuntimeError> {
383    let now_us = Utc::now().timestamp_micros();
384    let path_str = path.to_string_lossy().into_owned();
385    let sql = runtime.sql();
386    let mut w = sql
387        .writer()
388        .await
389        .map_err(|e| RuntimeError::Internal(format!("mirror_file: cursor writer: {e}")))?;
390    w.execute(SqlStatement {
391        sql: "INSERT INTO session_mirror_cursor(file_path, session_id, byte_offset, updated_at) \
392              VALUES(?1, ?2, ?3, ?4) \
393              ON CONFLICT(file_path) DO UPDATE SET \
394                session_id=COALESCE(excluded.session_id, session_mirror_cursor.session_id), \
395                byte_offset=excluded.byte_offset, \
396                updated_at=excluded.updated_at"
397            .into(),
398        params: vec![
399            SqlValue::Text(path_str),
400            session_id
401                .as_deref()
402                .map(|s| SqlValue::Text(s.to_string()))
403                .unwrap_or(SqlValue::Null),
404            SqlValue::Integer(new_offset as i64),
405            SqlValue::Integer(now_us),
406        ],
407        label: Some("session_mirror_cursor_only".into()),
408    })
409    .await
410    .map_err(|e| RuntimeError::Internal(format!("mirror_file: cursor write: {e}")))?;
411    Ok(())
412}
413
414// ── integration tests ─────────────────────────────────────────────────────────
415
416#[cfg(test)]
417mod tests {
418    use std::io::Write;
419    use std::sync::Arc;
420
421    use khive_runtime::{
422        AllowAllGate, BackendId, KhiveRuntime, Namespace, RuntimeConfig, RuntimeError,
423    };
424    use khive_storage::types::{SqlStatement, SqlValue};
425    use tempfile::{NamedTempFile, TempDir};
426
427    use super::*;
428    use crate::vocab::SESSION_SCHEMA_PLAN_STMTS;
429
430    /// Build a file-backed runtime and apply the session schema.
431    ///
432    /// `begin_tx` (used by `mirror_file`) requires a file-backed SQLite database;
433    /// in-memory SQLite does not support the WAL-mode transactions that `begin_tx`
434    /// opens.  The caller must keep the returned `TempDir` alive for the test.
435    async fn setup() -> (KhiveRuntime, TempDir) {
436        let dir = TempDir::new().expect("tempdir");
437        let db_path = dir.path().join("test.db");
438        let rt = KhiveRuntime::new(RuntimeConfig {
439            db_path: Some(db_path),
440            default_namespace: Namespace::local(),
441            embedding_model: None,
442            additional_embedding_models: vec![],
443            gate: Arc::new(AllowAllGate),
444            packs: vec!["kg".to_string()],
445            backend_id: BackendId::main(),
446            brain_profile: None,
447            visible_namespaces: vec![],
448            allowed_outbound_namespaces: vec![],
449            actor_id: None,
450        })
451        .expect("file-backed runtime");
452        apply_session_schema(&rt).await;
453        (rt, dir)
454    }
455
456    async fn apply_session_schema(rt: &KhiveRuntime) {
457        let sql = rt.sql();
458        let mut w = sql.writer().await.expect("writer");
459        for stmt in &SESSION_SCHEMA_PLAN_STMTS {
460            w.execute_script(stmt.to_string())
461                .await
462                .expect("schema stmt");
463        }
464        // w dropped here — releases the writer connection.
465    }
466
467    /// Count rows in a table.
468    async fn count_rows(rt: &KhiveRuntime, table: &str) -> i64 {
469        let sql = rt.sql();
470        let mut r = sql.reader().await.expect("reader");
471        let row = r
472            .query_row(SqlStatement {
473                sql: format!("SELECT COUNT(*) FROM {table}"),
474                params: vec![],
475                label: None,
476            })
477            .await
478            .expect("count query")
479            .expect("count row");
480        match row.columns.first().map(|c| &c.value) {
481            Some(SqlValue::Integer(n)) => *n,
482            _ => 0,
483        }
484    }
485
486    /// Retrieve the stored byte_offset for a file path.
487    async fn cursor_offset(rt: &KhiveRuntime, path_str: &str) -> Option<i64> {
488        let sql = rt.sql();
489        let mut r = sql.reader().await.expect("reader");
490        let row = r
491            .query_row(SqlStatement {
492                sql: "SELECT byte_offset FROM session_mirror_cursor WHERE file_path=?1".into(),
493                params: vec![SqlValue::Text(path_str.to_string())],
494                label: None,
495            })
496            .await
497            .expect("cursor query")?;
498        match row.columns.first().map(|c| &c.value) {
499            Some(SqlValue::Integer(n)) => Some(*n),
500            _ => None,
501        }
502    }
503
504    fn user_line(uuid: &str, session_id: &str, text: &str) -> String {
505        format!(
506            r#"{{"uuid":"{uuid}","sessionId":"{session_id}","type":"user","timestamp":"2026-06-29T10:00:00Z","message":{{"role":"user","content":"{text}"}}}}"#
507        )
508    }
509
510    /// A user line with NO `timestamp` field — `created_at` falls back to `now_us`.
511    fn user_line_no_ts(uuid: &str, session_id: &str, text: &str) -> String {
512        format!(
513            r#"{{"uuid":"{uuid}","sessionId":"{session_id}","type":"user","message":{{"role":"user","content":"{text}"}}}}"#
514        )
515    }
516
517    /// Retrieve the stored `last_seen_at` for a session id.
518    async fn last_seen_at(rt: &KhiveRuntime, session_id: &str) -> Option<i64> {
519        let sql = rt.sql();
520        let mut r = sql.reader().await.expect("reader");
521        let row = r
522            .query_row(SqlStatement {
523                sql: "SELECT last_seen_at FROM sessions WHERE id=?1".into(),
524                params: vec![SqlValue::Text(session_id.to_string())],
525                label: None,
526            })
527            .await
528            .expect("last_seen query")?;
529        match row.columns.first().map(|c| &c.value) {
530            Some(SqlValue::Integer(n)) => Some(*n),
531            _ => None,
532        }
533    }
534
535    #[tokio::test]
536    async fn test_mirror_three_lines_and_idempotency() {
537        let (rt, _dir) = setup().await;
538
539        // Build a fixture JSONL with 3 lines, all ending in '\n'.
540        let line1 = user_line("uuid-1", "sess-A", "Hello");
541        let line2 = user_line("uuid-2", "sess-A", "World");
542        let line3 = user_line("uuid-3", "sess-A", "Done");
543
544        let mut file = NamedTempFile::new().expect("tmpfile");
545        writeln!(file, "{line1}").unwrap();
546        writeln!(file, "{line2}").unwrap();
547        writeln!(file, "{line3}").unwrap();
548
549        let path = file.path().to_path_buf();
550
551        // First call: should insert all 3 rows.
552        let stats = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
553            .await
554            .expect("mirror_file first call");
555        assert_eq!(stats.inserted, 3, "should insert 3 new messages");
556        assert_eq!(stats.scanned, 3, "should scan 3 lines");
557        assert!(stats.new_offset > 0, "offset should advance");
558
559        let msg_count = count_rows(&rt, "session_messages").await;
560        assert_eq!(msg_count, 3, "3 messages in DB");
561
562        let session_count = count_rows(&rt, "sessions").await;
563        assert_eq!(session_count, 1, "1 session row");
564
565        // Idempotency: second call over the SAME range inserts 0 rows.
566        let stats2 = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
567            .await
568            .expect("mirror_file second call");
569        assert_eq!(stats2.inserted, 0, "second pass must insert 0 rows");
570        assert_eq!(count_rows(&rt, "session_messages").await, 3);
571
572        // Offset-aware: calling from the advanced offset finds nothing new.
573        let stats3 = mirror_file(&rt, &path, stats.new_offset, MirrorSource::ClaudeCode, None)
574            .await
575            .expect("mirror_file from new_offset");
576        assert_eq!(stats3.inserted, 0, "no new data past advanced offset");
577        assert_eq!(stats3.new_offset, stats.new_offset);
578
579        // Cursor was recorded.
580        let stored_offset = cursor_offset(&rt, &path.to_string_lossy()).await;
581        assert!(stored_offset.is_some(), "cursor should be recorded");
582        assert_eq!(stored_offset.unwrap(), stats.new_offset as i64);
583    }
584
585    #[tokio::test]
586    async fn test_partial_trailing_line_not_consumed() {
587        let (rt, _dir) = setup().await;
588
589        let line1 = user_line("uuid-p1", "sess-B", "Complete");
590        // Write one complete line + a partial line without trailing '\n'.
591        let partial = r#"{"uuid":"uuid-p2","sessionId":"sess-B","type":"user""#;
592
593        let mut file = NamedTempFile::new().expect("tmpfile");
594        writeln!(file, "{line1}").unwrap(); // complete line (has \n)
595        write!(file, "{partial}").unwrap(); // partial — NO trailing \n
596
597        let path = file.path().to_path_buf();
598        let full_len = std::fs::metadata(&path).unwrap().len();
599
600        let stats = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
601            .await
602            .expect("mirror_file partial");
603
604        // Only the complete line should be consumed.
605        assert_eq!(stats.inserted, 1, "only 1 complete line inserted");
606        assert!(
607            stats.new_offset < full_len,
608            "new_offset {new} must be less than file_len {full_len}",
609            new = stats.new_offset
610        );
611
612        // The partial bytes remain; calling again from new_offset finds no complete lines.
613        let stats2 = mirror_file(&rt, &path, stats.new_offset, MirrorSource::ClaudeCode, None)
614            .await
615            .expect("second call");
616        assert_eq!(
617            stats2.inserted, 0,
618            "partial line must not be consumed on re-poll"
619        );
620        assert_eq!(
621            stats2.new_offset, stats.new_offset,
622            "offset must not advance on partial-only content"
623        );
624    }
625
626    #[tokio::test]
627    async fn test_duplicate_uuid_across_two_calls() {
628        let (rt, _dir) = setup().await;
629
630        let line = user_line("uuid-dup", "sess-C", "First");
631
632        let mut file = NamedTempFile::new().expect("tmpfile");
633        writeln!(file, "{line}").unwrap();
634
635        let path = file.path().to_path_buf();
636
637        // First call inserts.
638        let s1 = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
639            .await
640            .unwrap();
641        assert_eq!(s1.inserted, 1);
642
643        // Append same uuid again.
644        writeln!(file, "{line}").unwrap();
645
646        // Second call from offset 0 should see both lines but insert 0 new rows.
647        let s2 = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
648            .await
649            .unwrap();
650        assert_eq!(s2.inserted, 0, "duplicate uuid must not be re-inserted");
651        assert_eq!(count_rows(&rt, "session_messages").await, 1);
652
653        // Incremental: call from first call's new_offset; the second line is the dup.
654        let s3 = mirror_file(&rt, &path, s1.new_offset, MirrorSource::ClaudeCode, None)
655            .await
656            .unwrap();
657        assert_eq!(s3.inserted, 0, "incremental dup must also insert 0");
658    }
659
660    #[tokio::test]
661    async fn test_replay_does_not_mutate_session_metadata() {
662        // Regression for the replay-idempotency finding: a timestamp-missing
663        // event's `created_at` falls back to `now_us`, which differs between
664        // calls. A pure replay (0 new messages) must NOT advance `last_seen_at`
665        // or otherwise touch the session row.
666        let (rt, _dir) = setup().await;
667
668        let line = user_line_no_ts("uuid-nts", "sess-NTS", "no timestamp here");
669        let mut file = NamedTempFile::new().expect("tmpfile");
670        writeln!(file, "{line}").unwrap();
671        let path = file.path().to_path_buf();
672
673        let s1 = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
674            .await
675            .unwrap();
676        assert_eq!(s1.inserted, 1);
677        let seen_after_first = last_seen_at(&rt, "sess-NTS")
678            .await
679            .expect("session row exists");
680
681        // Replay from offset 0: re-scans the same line, inserts 0, and must
682        // leave last_seen_at byte-identical even though now_us has advanced.
683        let s2 = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
684            .await
685            .unwrap();
686        assert_eq!(s2.inserted, 0, "replay must insert 0 rows");
687        let seen_after_replay = last_seen_at(&rt, "sess-NTS").await.unwrap();
688        assert_eq!(
689            seen_after_first, seen_after_replay,
690            "replay must not advance last_seen_at for a timestamp-missing event"
691        );
692    }
693
694    #[tokio::test]
695    async fn test_empty_file_is_a_no_op() {
696        let (rt, _dir) = setup().await;
697
698        let file = NamedTempFile::new().expect("tmpfile");
699        let path = file.path().to_path_buf();
700
701        let stats = mirror_file(&rt, &path, 0, MirrorSource::ClaudeCode, None)
702            .await
703            .unwrap();
704        assert_eq!(stats.inserted, 0);
705        assert_eq!(stats.scanned, 0);
706        assert_eq!(stats.new_offset, 0);
707    }
708
709    #[tokio::test]
710    async fn test_missing_file_returns_error() {
711        let (rt, _dir) = setup().await;
712        let bad_path = std::path::PathBuf::from("/nonexistent/path/session.jsonl");
713        let result = mirror_file(&rt, &bad_path, 0, MirrorSource::ClaudeCode, None).await;
714        assert!(
715            matches!(result, Err(RuntimeError::Internal(_))),
716            "missing file should return Internal error"
717        );
718    }
719
720    // ── Codex source integration tests ────────────────────────────────────────
721
722    /// Build a minimal Codex response_item/message line. Block type mirrors the
723    /// real shape: `input_text` for user messages, `output_text` for assistant
724    /// messages (the generic `text` type does not occur in real Codex transcripts).
725    fn codex_message_line(role: &str, text: &str) -> String {
726        let block_type = if role == "assistant" {
727            "output_text"
728        } else {
729            "input_text"
730        };
731        format!(
732            r#"{{"type":"response_item","timestamp":"2026-06-30T08:00:00Z","payload":{{"type":"message","role":"{role}","content":[{{"type":"{block_type}","text":"{text}"}}]}}}}"#
733        )
734    }
735
736    /// Build a minimal Codex session_meta line.
737    fn codex_meta_line(session_id: &str, cwd: &str, branch: &str) -> String {
738        format!(
739            r#"{{"type":"session_meta","timestamp":"2026-06-30T08:00:00Z","payload":{{"id":"{session_id}","cwd":"{cwd}","git":{{"branch":"{branch}","commit_hash":"abc","repository_url":"https://github.com/example/repo"}}}}}}"#
740        )
741    }
742
743    /// Build a Codex event_msg line (should be skipped).
744    fn codex_event_msg_line() -> String {
745        r#"{"type":"event_msg","timestamp":"2026-06-30T08:00:00Z","payload":{"type":"user_message","content":"should be skipped"}}"#.to_string()
746    }
747
748    #[tokio::test]
749    async fn test_codex_mirror_inserts_with_source_codex() {
750        let (rt, _dir) = setup().await;
751
752        let session_id = "cdx-sess-0001-0001-0001-000000000001";
753        let meta = codex_meta_line(session_id, "/home/lion/proj", "feat-x");
754        let user_msg = codex_message_line("user", "Hello from Codex");
755        let asst_msg = codex_message_line("assistant", "Hello back from Codex");
756        let skip_msg = codex_event_msg_line();
757
758        let mut file = NamedTempFile::new().expect("tmpfile");
759        writeln!(file, "{meta}").unwrap();
760        writeln!(file, "{user_msg}").unwrap();
761        writeln!(file, "{asst_msg}").unwrap();
762        writeln!(file, "{skip_msg}").unwrap();
763
764        let path = file.path().to_path_buf();
765
766        // Mirror the file as Codex source.
767        let stats = mirror_file(&rt, &path, 0, MirrorSource::Codex, Some(session_id))
768            .await
769            .expect("codex mirror_file");
770
771        // session_meta + 2 response_item/message rows = 3 parseable, event_msg skipped.
772        assert_eq!(stats.inserted, 3, "meta + 2 messages inserted");
773        assert_eq!(
774            stats.scanned, 4,
775            "4 lines total (including skipped event_msg)"
776        );
777        assert!(stats.new_offset > 0);
778
779        // Session row exists with source='codex'.
780        let sql = rt.sql();
781        let mut r = sql.reader().await.expect("reader");
782        let session_row = r
783            .query_row(SqlStatement {
784                sql: "SELECT source FROM sessions WHERE id=?1".into(),
785                params: vec![SqlValue::Text(session_id.to_string())],
786                label: None,
787            })
788            .await
789            .expect("query ok")
790            .expect("session row must exist");
791        match session_row.columns.first().map(|c| &c.value) {
792            Some(SqlValue::Text(s)) => assert_eq!(s, "codex", "source must be 'codex'"),
793            other => panic!("unexpected source value: {other:?}"),
794        }
795
796        // All 3 message rows are stored.
797        assert_eq!(count_rows(&rt, "session_messages").await, 3);
798
799        // The two response_item/message rows carry their real input_text/
800        // output_text content through to session_messages.text — not just a
801        // row count, but the actual extracted string for each role.
802        let mut r2 = sql.reader().await.expect("reader");
803        let rows = r2
804            .query_all(SqlStatement {
805                sql: "SELECT role, text FROM session_messages \
806                      WHERE session_id=?1 AND role IS NOT NULL ORDER BY seq"
807                    .into(),
808                params: vec![SqlValue::Text(session_id.to_string())],
809                label: None,
810            })
811            .await
812            .expect("query ok");
813        let texts: Vec<(String, String)> = rows
814            .iter()
815            .map(|row| {
816                let role = match row.get("role") {
817                    Some(SqlValue::Text(s)) => s.clone(),
818                    other => panic!("unexpected role value: {other:?}"),
819                };
820                let text = match row.get("text") {
821                    Some(SqlValue::Text(s)) => s.clone(),
822                    other => panic!("unexpected text value: {other:?}"),
823                };
824                (role, text)
825            })
826            .collect();
827        assert_eq!(
828            texts,
829            vec![
830                ("user".to_string(), "Hello from Codex".to_string()),
831                ("assistant".to_string(), "Hello back from Codex".to_string()),
832            ],
833            "input_text/output_text blocks must round-trip to session_messages.text by role"
834        );
835    }
836
837    #[tokio::test]
838    async fn test_codex_event_id_is_stable_and_idempotent() {
839        // Verifies that: (a) synthetic uuid format is "{session_id}:{offset}",
840        // and (b) a second mirror_file pass over the same bytes inserts 0 rows.
841        let (rt, _dir) = setup().await;
842
843        let session_id = "cdx-sess-idem-0001-0001-000000000002";
844        let user_msg = codex_message_line("user", "Idempotency test");
845
846        let mut file = NamedTempFile::new().expect("tmpfile");
847        writeln!(file, "{user_msg}").unwrap();
848
849        let path = file.path().to_path_buf();
850
851        // First pass.
852        let s1 = mirror_file(&rt, &path, 0, MirrorSource::Codex, Some(session_id))
853            .await
854            .unwrap();
855        assert_eq!(s1.inserted, 1);
856
857        // Verify the stored id matches the expected synthetic format.
858        let sql = rt.sql();
859        let mut r = sql.reader().await.expect("reader");
860        let msg_row = r
861            .query_row(SqlStatement {
862                sql: "SELECT id FROM session_messages WHERE session_id=?1".into(),
863                params: vec![SqlValue::Text(session_id.to_string())],
864                label: None,
865            })
866            .await
867            .expect("query ok")
868            .expect("message row must exist");
869        let stored_id = match msg_row.columns.first().map(|c| &c.value) {
870            Some(SqlValue::Text(s)) => s.clone(),
871            other => panic!("unexpected id type: {other:?}"),
872        };
873        // The line starts at byte offset 0.
874        let expected_id = format!("{session_id}:0");
875        assert_eq!(
876            stored_id, expected_id,
877            "synthetic uuid must be {{session_id}}:{{offset}}"
878        );
879
880        // Second pass from offset 0: same lines, 0 new rows (idempotent).
881        let s2 = mirror_file(&rt, &path, 0, MirrorSource::Codex, Some(session_id))
882            .await
883            .unwrap();
884        assert_eq!(s2.inserted, 0, "second pass must be idempotent");
885        assert_eq!(count_rows(&rt, "session_messages").await, 1);
886
887        // Incremental pass from advanced offset: no new data.
888        let s3 = mirror_file(
889            &rt,
890            &path,
891            s1.new_offset,
892            MirrorSource::Codex,
893            Some(session_id),
894        )
895        .await
896        .unwrap();
897        assert_eq!(s3.inserted, 0, "incremental pass finds nothing new");
898    }
899
900    #[tokio::test]
901    async fn test_codex_and_cc_are_independent_sessions() {
902        // Both sources can coexist in the same DB; source column distinguishes them.
903        let (rt, _dir) = setup().await;
904
905        let cc_line = user_line("cc-uuid-1", "cc-sess-1", "from claude code");
906        let mut cc_file = NamedTempFile::new().expect("cc tmpfile");
907        writeln!(cc_file, "{cc_line}").unwrap();
908
909        let cdx_session_id = "cdx-sess-coex-0001-0001-000000000003";
910        let cdx_msg = codex_message_line("user", "from codex");
911        let mut cdx_file = NamedTempFile::new().expect("cdx tmpfile");
912        writeln!(cdx_file, "{cdx_msg}").unwrap();
913
914        mirror_file(&rt, cc_file.path(), 0, MirrorSource::ClaudeCode, None)
915            .await
916            .unwrap();
917
918        mirror_file(
919            &rt,
920            cdx_file.path(),
921            0,
922            MirrorSource::Codex,
923            Some(cdx_session_id),
924        )
925        .await
926        .unwrap();
927
928        assert_eq!(count_rows(&rt, "sessions").await, 2);
929        assert_eq!(count_rows(&rt, "session_messages").await, 2);
930
931        // Verify sources are distinct.
932        let sql = rt.sql();
933        let mut r = sql.reader().await.expect("reader");
934        let rows = r
935            .query_all(SqlStatement {
936                sql: "SELECT source FROM sessions ORDER BY source".into(),
937                params: vec![],
938                label: None,
939            })
940            .await
941            .expect("query ok");
942        let sources: Vec<String> = rows
943            .iter()
944            .filter_map(|row| match row.get("source") {
945                Some(SqlValue::Text(s)) => Some(s.clone()),
946                _ => None,
947            })
948            .collect();
949        assert_eq!(sources, vec!["claude_code", "codex"]);
950    }
951}