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 via the parser selected by [`LineTailSource`], and
5//! writes one bounded chunk (never the whole file at once) to the session
6//! mirror tables per call — callers poll repeatedly to drain large deltas.
7//! `INSERT OR IGNORE` keyed by the event UUID makes replays idempotent.
8//!
9//! See `crates/khive-pack-session/docs/api/mirror-ingest.md` for the full bounded
10//! tail-read algorithm, the oversized/unterminated-line handling
11//! (PACKSESSION-AUD-003), and the write-path (ADR-099 D5) rationale.
12
13use 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/// The full ADR-080 mirror-source contract — the closed set of sources
24/// `sessions.source` can hold (`docs/adr/ADR-080-session-pack-oss-storage-mechanism.md`,
25/// "Mirror sources — closed set"). Adding a source requires amending that ADR
26/// section and this enum together.
27///
28/// This is a superset of [`LineTailSource`]: `ChatGptExport` ingests via
29/// whole-file re-parse (`mirror_chatgpt_export_file`), not the per-line
30/// dispatch `LineTailSource` selects, so it has no `LineTailSource` variant.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum MirrorSource {
33    /// Claude Code (`~/.claude/projects/<slug>/<uuid>.jsonl`).
34    ClaudeCode,
35    /// Codex CLI (`~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`).
36    Codex,
37    /// ChatGPT data export (`<exports dir>/**/conversations.json`).
38    ChatGptExport,
39}
40
41impl MirrorSource {
42    /// The string written to `sessions.source`.
43    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/// Identifies which CLI produced the JSONL file being mirrored, for the
62/// purpose of selecting `mirror_file`'s per-line parser.
63///
64/// This is narrower than [`MirrorSource`]: it covers only the line-tail
65/// sources (append-only JSONL, tailed by byte offset). ChatGPT export
66/// ingestion is whole-file re-parse, not line-tail, so it has no variant
67/// here — see [`mirror_chatgpt_export_file`].
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum LineTailSource {
70    /// Claude Code (`~/.claude/projects/<slug>/<uuid>.jsonl`).
71    ClaudeCode,
72    /// Codex CLI (`~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`).
73    Codex,
74}
75
76/// Statistics returned by a single `mirror_file` call.
77#[derive(Debug, Default, Clone, PartialEq)]
78pub struct MirrorStats {
79    /// Number of new message rows inserted (0 if all were already present).
80    pub inserted: u64,
81    /// Number of complete lines scanned (including duplicates).
82    pub scanned: u64,
83    /// Byte offset advanced to (only past complete lines; partial trailing line excluded).
84    pub new_offset: u64,
85}
86
87/// Ceiling on bytes read per `mirror_file` call in production (8 MiB); bounds
88/// worst-case memory on a very large accumulated delta. See
89/// `crates/khive-pack-session/docs/api/mirror-ingest.md#mirrorlimits--per-pass-caps`.
90const MIRROR_MAX_BYTES_PER_PASS: usize = 8 * 1024 * 1024;
91
92/// Ceiling on parsed events collected per `mirror_file` call in production.
93const MIRROR_MAX_EVENTS_PER_PASS: usize = 1024;
94
95/// Hard ceiling on a single JSONL line's buffered size, enforced by
96/// `read_line_bounded` independently of `max_bytes_per_pass` (PACKSESSION-AUD-003).
97const MIRROR_MAX_LINE_BYTES: usize = MIRROR_MAX_BYTES_PER_PASS;
98
99/// Per-call caps on how much of a file's delta `mirror_file` reads/parses
100/// before writing a bounded chunk; tests use smaller caps to force multi-pass
101/// behavior without giant fixtures.
102#[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
119/// Read new bytes of `path` starting at `start_offset`, parse complete lines
120/// using the parser selected by `source`, and upsert them idempotently into the
121/// session mirror tables.
122///
123/// For `LineTailSource::Codex`, `codex_session_id` must be the session UUID
124/// derived from the filename; it is used both to key the `sessions` row and to
125/// synthesise per-line event UUIDs (`"{session_id}:{abs_byte_offset}"`).
126/// For `LineTailSource::ClaudeCode`, `codex_session_id` is ignored (the session
127/// UUID is embedded in each line).
128///
129/// Returns stats including the advanced byte offset.  A partial trailing line
130/// (no terminating `\n`) is left for the next poll — `new_offset` is set to
131/// the byte after the last complete `\n`.
132///
133/// One bad file or one bad line does NOT kill the loop: per-file errors propagate
134/// to the caller (the service loop logs and continues); per-line parse failures
135/// are silently skipped (the parser returns `None`).
136pub 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
154/// A single bounded read pass: at most `limits.max_bytes_per_pass` bytes and
155/// `limits.max_events_per_pass` parsed events, stopping on a line boundary.
156struct MirrorChunk {
157    events: Vec<parse::ParsedEvent>,
158    scanned: u64,
159    new_offset: u64,
160}
161
162/// Outcome of `read_line_bounded` for one line. See
163/// `crates/khive-pack-session/docs/api/mirror-ingest.md#lineread--read_line_bounded--the-packsession-aud-003-bound`
164/// for the full PACKSESSION-AUD-003 rationale.
165#[derive(Debug)]
166enum LineRead {
167    /// EOF with nothing read at all.
168    Eof,
169    /// EOF before a terminating `\n`; caller must not advance past it.
170    Partial,
171    /// A complete line fit within `max_line_bytes`.
172    Complete { bytes: usize },
173    /// A complete line exceeded `max_line_bytes`; caller must skip it, not
174    /// parse `buf` (never fully populated for this case).
175    Oversized { bytes: usize },
176    /// Exceeded `max_line_bytes` with no `\n` found yet, and NOT at EOF.
177    /// Unlike `Oversized`, the caller must not advance past it.
178    OversizedUnterminated { bytes: usize },
179}
180
181/// Read one line from `reader` into `buf`, never buffering — or reading —
182/// more than `max_line_bytes` regardless of how long the underlying line
183/// turns out to be (the PACKSESSION-AUD-003 bound; see the docs guide above
184/// for why `BufRead::read_until` alone is unsafe here).
185fn 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            // Already over the cap and this fill_buf window had no `\n`:
227            // stop here rather than looping onward toward EOF (or forever,
228            // if the file keeps growing). See the PACKSESSION-AUD-003 bound
229            // above.
230            return Ok(LineRead::OversizedUnterminated { bytes: total });
231        }
232        // No `\n` in this fill_buf window yet, and still under the cap;
233        // loop for more data, buffering normally.
234    }
235}
236
237/// Read at most one bounded chunk of `path` starting at `start_offset`. A
238/// complete line whose buffered size exceeds `limits.max_line_bytes` is
239/// skipped outright (bytes counted, offset advances past it, `tracing::warn!`
240/// names the file/offset — PACKSESSION-AUD-003, no silent coercion); a
241/// partial trailing line is left for the next call.
242fn 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, // leave partial trailing line for next pass
282            LineRead::OversizedUnterminated { bytes } => {
283                // Already over max_line_bytes with no `\n` found in this
284                // bounded read (see `read_line_bounded`'s bound above):
285                // do NOT advance new_offset past line_offset. The next call
286                // re-reads from the same line_offset and is bounded the
287                // same way — cheap and repeatable, whether the file is
288                // still growing (a later pass will eventually see the
289                // terminator and fall into the `Oversized` skip-and-advance
290                // arm below) or genuinely corrupt/truncated (every later
291                // poll or daemon restart repeats this same bounded read,
292                // never the unbounded tail scan PACKSESSION-AUD-003 flagged).
293                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; // blank line: bytes consumed, but not counted as scanned
323                }
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
350/// Read, parse, and write one bounded chunk starting at `start_offset`.
351async 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        // Nothing was consumed this pass (EOF, or only a partial trailing
369        // line was seen) — there is no advanced cursor to persist.
370        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        // Apply cursor update even when there are no parseable events — e.g.
379        // a chunk made entirely of blank lines, unparseable lines, or
380        // skipped oversized lines — so we don't re-read the same bytes on
381        // the next call. `chunk.new_offset > start_offset` here (checked
382        // above), so real bytes were durably consumed even though
383        // `chunk.scanned` may be 0. A failure here must propagate — silently
384        // swallowing it would let the cursor and the already-consumed bytes
385        // drift apart.
386        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
405/// Default ceiling (256 MiB) on a ChatGPT export `conversations.json` file
406/// read in one [`mirror_chatgpt_export_file`] pass — a ceiling on the entire
407/// file, not a per-pass delta (unlike the JSONL line-tail sources). An export
408/// over this size is skipped (warn-logged) and the cursor is left untouched
409/// so it is retried on every later tick rather than dropped (PACKSESSION-AUD-003).
410/// See `crates/khive-pack-session/docs/api/mirror-ingest.md#chatgpt-export-whole-file-re-parse-mirror_chatgpt_export_file`.
411const DEFAULT_CHATGPT_MAX_BYTES: u64 = 256 * 1024 * 1024;
412
413/// Resolve the ChatGPT export size ceiling from `KHIVE_MIRROR_CHATGPT_MAX_BYTES`,
414/// falling back to [`DEFAULT_CHATGPT_MAX_BYTES`] for missing, non-numeric, or
415/// zero values (zero would skip every export unconditionally, so it is
416/// treated the same as unset).
417fn 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
425/// Read the whole ChatGPT export `conversations.json` at `path`, parse every
426/// conversation's mapping tree via [`parse::parse_chatgpt_export`], and
427/// upsert every message-bearing event idempotently into the session mirror
428/// tables in a single transaction. Unlike `mirror_file`, this always
429/// re-reads and re-parses the whole file (a ChatGPT export has no stable
430/// "new bytes" boundary to tail) — `start_offset` is only a cheap
431/// re-poll guard: if the file has not grown past it, nothing is read.
432///
433/// `new_offset` is set to the whole file's byte length only after a
434/// successful parse and commit; any IO, parse, or DB error leaves the
435/// persisted cursor untouched, so a partially-downloaded export is retried
436/// whole on the next tick, never half-consumed. An export over
437/// `chatgpt_max_bytes` is skipped (warn-logged) without ever calling
438/// `read_to_string`. See the docs guide linked above `chatgpt_max_bytes`
439/// for the full rationale.
440pub 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
449/// Implementation behind [`mirror_chatgpt_export_file`], taking an explicit
450/// `max_bytes` ceiling so tests can exercise the oversized-skip path without
451/// a giant fixture or racing on process-global environment variables.
452async 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
511/// Upsert `events` and the mirror cursor for `path` in one transaction.
512/// Shared by `mirror_file`'s line-tail path and `mirror_chatgpt_export_file`'s
513/// whole-file path. See
514/// `crates/khive-pack-session/docs/api/mirror-ingest.md#write-path-write_events_and_cursor-and-friends-adr-099-d5`
515/// for the ADR-099 D5 suspension-free rationale.
516async 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
563/// The synchronous-DML body of `write_events_and_cursor`, run inside one
564/// `atomic_unit` closure. Takes a plain `&mut dyn SqlWriter` (not `&mut dyn
565/// SqlTransaction`) because `atomic_unit` owns the transaction boundary
566/// entirely — this function must not, and does not, issue its own
567/// `BEGIN`/`COMMIT`/`ROLLBACK`.
568async 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        // sessions row: create-only (see docs guide — replay is a no-op via
588        // `DO NOTHING`; `last_seen_at` advances below only on a new message).
589        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        // session_messages insert, idempotent via INSERT OR IGNORE.
627        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        // Advance session metadata only when a new message landed — keeps
668        // last_seen_at monotonic (MAX) and backfills NULL metadata; a pure
669        // replay (affected == 0) touches nothing (see docs guide).
670        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    // Refresh message_count for each distinct session touched; skipped on a
713    // pure replay (inserted == 0) since counts cannot have changed.
714    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(); // deterministic order for tests
722
723        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    // No explicit COMMIT: `atomic_unit` owns the transaction boundary and
747    // commits on `Ok` / rolls back the whole unit on `Err`.
748    Ok(MirrorStats {
749        inserted,
750        scanned,
751        new_offset,
752    })
753}
754
755/// Upsert the `session_mirror_cursor` row for `path` inside the open
756/// `atomic_unit` transaction — issues only the one cursor DML statement, no
757/// transaction control of its own.
758async 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
797/// Write only the cursor row (no events); used when a pass consumed bytes
798/// but produced no parseable events, so the offset still advances past
799/// blank/unparseable content. A failure here must propagate — see
800/// `crates/khive-pack-session/docs/api/mirror-ingest.md#write-path-write_events_and_cursor-and-friends-adr-099-d5`.
801async 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    /// Build a file-backed runtime (exercises the real `atomic_unit`
853    /// single-writer path) and apply the session schema. Caller must keep
854    /// the returned `TempDir` alive.
855    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        // w dropped here — releases the writer connection.
886    }
887
888    /// Count rows in a table.
889    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    /// Retrieve the stored byte_offset for a file path.
908    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    /// A user line with NO `timestamp` field — `created_at` falls back to `now_us`.
932    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    /// Retrieve the stored `last_seen_at` for a session id.
939    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        // Build a fixture JSONL with 3 lines, all ending in '\n'.
961        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        // First call: should insert all 3 rows.
973        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        // Idempotency: second call over the SAME range inserts 0 rows.
987        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        // Offset-aware: calling from the advanced offset finds nothing new.
994        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        // Cursor was recorded.
1007        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        // PACKSESSION-AUD-003 regression: multi-pass bounded reads (see docs guide).
1015        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        // All 6 fixture lines are byte-identical in length, so capping at
1029        // exactly two lines' worth of bytes forces a 2-line-per-pass split
1030        // without needing a giant fixture.
1031        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        // All 6 rows landed across 3 bounded passes, and the cursor reflects
1086        // the full file — no pass allocated or inserted the entire file at
1087        // once.
1088        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        // A pass with no remaining bytes is a clean no-op.
1095        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        // PACKSESSION-AUD-003 regression: oversized complete line (see docs guide).
1112        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        // PACKSESSION-AUD-003 regression: under-cap line followed by an
1162        // oversized one (see docs guide).
1163        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; // + '\n'
1167        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    /// Counts every byte pulled through `Read::read`, for asserting a hard
1213    /// ceiling on `read_line_bounded`'s reads independent of buffer size.
1214    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        // PACKSESSION-AUD-003 regression: oversized-unterminated reads are
1230        // capped per call, not just per buffered byte (see docs guide).
1231        let max_line_bytes: usize = 64;
1232        let buf_capacity: usize = 256;
1233        // Far larger than max_line_bytes + a handful of buffer refills, and
1234        // containing NO '\n' anywhere — the pathological unterminated case.
1235        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        // The load-bearing assertion: total bytes ever pulled from the
1263        // underlying 200,000-byte source must be bounded to roughly
1264        // max_line_bytes plus a small, constant number of buffer refills —
1265        // never anywhere close to scanning the whole remaining file.
1266        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        // PACKSESSION-AUD-003 regression: unterminated-oversized-line cursor
1280        // handling and bounded retry (see docs guide).
1281        let (rt, _dir) = setup().await;
1282
1283        let max_line_bytes: usize = 256;
1284        // One line, far larger than the cap, with no terminating '\n' at all.
1285        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        // First pass: the oversized-unterminated line must not advance the
1299        // cursor at all (same policy as an ordinary `Partial`).
1300        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        // Second pass from the persisted (unchanged) offset behaves
1317        // identically — a durable, bounded retry, never a wedge that grows
1318        // unboundedly worse, and no replay of previously-seen bytes as new
1319        // events (there were none).
1320        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        // Now the line completes (append a terminating '\n' and a bit more,
1336        // simulating the file finishing its write): it must be recognized
1337        // as the ordinary complete-oversized-line skip, advance past it, and
1338        // ingest anything that follows normally.
1339        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(); // terminate the huge line
1346            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        // Guard: still-growing partial line under the cap must not regress
1373        // from the oversized-unterminated handling (see docs guide).
1374        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        // Partial trailing line: valid JSON-shaped prefix, no newline yet.
1380        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        // The file "grows": the trailing line now gets its newline.
1400        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        // PACKSESSION-AUD-003 regression: blank-line runs are bounded and the
1422        // cursor persists on every durable advance (see docs guide).
1423        let (rt, _dir) = setup().await;
1424
1425        let mut file = NamedTempFile::new().expect("tmpfile");
1426        for _ in 0..500 {
1427            writeln!(file).unwrap(); // blank line: just "\n"
1428        }
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        // A tiny per-pass byte cap forces the blank-line run across multiple
1434        // passes instead of reading straight to EOF in one call.
1435        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        // The cursor must be durably persisted even though `scanned == 0`.
1458        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        // Repeated calls continue from the persisted offset (not from 0) and
1466        // eventually reach EOF, never re-reading already-consumed blanks.
1467        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; // EOF reached, no further progress
1482            }
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        // Write one complete line + a partial line without trailing '\n'.
1497        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(); // complete line (has \n)
1501        write!(file, "{partial}").unwrap(); // partial — NO trailing \n
1502
1503        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        // Only the complete line should be consumed.
1511        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        // The partial bytes remain; calling again from new_offset finds no complete lines.
1519        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        // First call inserts.
1550        let s1 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1551            .await
1552            .unwrap();
1553        assert_eq!(s1.inserted, 1);
1554
1555        // Append same uuid again.
1556        writeln!(file, "{line}").unwrap();
1557
1558        // Second call from offset 0 should see both lines but insert 0 new rows.
1559        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        // Incremental: call from first call's new_offset; the second line is the dup.
1566        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        // Replay-idempotency regression (see docs guide): a pure replay must
1575        // not advance last_seen_at.
1576        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        // Replay from offset 0: re-scans the same line, inserts 0, and must
1592        // leave last_seen_at byte-identical even though now_us has advanced.
1593        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    // ── Codex source integration tests ────────────────────────────────────────
1631
1632    /// Build a minimal Codex response_item/message line (`input_text` for
1633    /// user, `output_text` for assistant — the generic `text` type does not
1634    /// occur in real Codex transcripts).
1635    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    /// Build a minimal Codex session_meta line.
1647    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    /// Build a Codex event_msg line (should be skipped).
1654    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        // Mirror the file as Codex source.
1677        let stats = mirror_file(&rt, &path, 0, LineTailSource::Codex, Some(session_id))
1678            .await
1679            .expect("codex mirror_file");
1680
1681        // session_meta + 2 response_item/message rows = 3 parseable, event_msg skipped.
1682        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        // Session row exists with source='codex'.
1690        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        // All 3 message rows are stored.
1707        assert_eq!(count_rows(&rt, "session_messages").await, 3);
1708
1709        // The two response_item/message rows carry their real input_text/
1710        // output_text content through to session_messages.text — not just a
1711        // row count, but the actual extracted string for each role.
1712        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        // Verifies that: (a) synthetic uuid format is "{session_id}:{offset}",
1750        // and (b) a second mirror_file pass over the same bytes inserts 0 rows.
1751        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        // First pass.
1762        let s1 = mirror_file(&rt, &path, 0, LineTailSource::Codex, Some(session_id))
1763            .await
1764            .unwrap();
1765        assert_eq!(s1.inserted, 1);
1766
1767        // Verify the stored id matches the expected synthetic format.
1768        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        // The line starts at byte offset 0.
1784        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        // Second pass from offset 0: same lines, 0 new rows (idempotent).
1791        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        // Incremental pass from advanced offset: no new data.
1798        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        // Both sources can coexist in the same DB; source column distinguishes them.
1813        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        // Verify sources are distinct.
1842        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    // ── ChatGPT export whole-file ingest tests ────────────────────────────────
1863    //
1864    // All fixtures below are hand-authored synthetic JSON, not real export
1865    // content. Node ids are set equal to their own `message.id` so that
1866    // `parent_uuid` (which threads through the mapping node id, per
1867    // `parse::build_chatgpt_event`) resolves to the expected message uuid.
1868
1869    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        // Re-ingest from offset 0 (the service always re-reads the whole file
2043        // for this source): same event uuids, INSERT OR IGNORE must dedup.
2044        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        // Seed the path with a valid (if empty) export and record its cursor.
2179        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        // Overwrite with a longer, malformed (valid-JSON-but-not-an-array) body.
2190        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        // PACKSESSION-AUD-003 regression: oversized ChatGPT exports are
2222        // skipped without reading (see docs guide).
2223        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; // smaller than even an empty-array export
2228        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        // Assembled from fragments at runtime so no credential-shaped literal
2255        // is committed to the repo; matches the AWS-key shape already covered
2256        // by `khive_runtime::secret_gate`'s own detector tests.
2257        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    /// SS6 invariants #4/#5 (error never advances cursor; one transaction per
2329    /// pass) — see
2330    /// `crates/khive-pack-session/docs/api/mirror-ingest.md#test_mid_transaction_db_error_leaves_no_partial_state_and_cursor_unadvanced`
2331    /// for why this drives `atomic_unit` directly instead of through crafted event data.
2332    #[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                // First write succeeds — mirrors event 1's session row in a
2342                // multi-event file pass.
2343                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                // Cursor advance succeeds too — mirrors `upsert_cursor_on_writer`
2355                // running near the end of `write_events_and_cursor_on_writer`.
2356                upsert_cursor_on_writer(writer, &path_owned, Some("mid-tx-session"), 999, 1)
2357                    .await?;
2358
2359                // Third write fails with a genuine (non-suppressed) SQL error —
2360                // mirrors a mid-loop DB failure on a later event in the same file.
2361                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        // `atomic_unit` itself must surface the error and roll back the
2374        // whole unit — no explicit `commit()`/`drop()` orchestration is the
2375        // caller's job anymore; the seam owns it.
2376        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    /// Build a bare, file-backed, write-queue-enabled `SqlAccess` handle
2395    /// (sidesteps the process-global `KHIVE_WRITE_QUEUE` env var race — see
2396    /// docs guide ADR-099 D5 notes).
2397    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    /// ADR-099 D5 acceptance: `write_events_and_cursor_on_writer` is
2417    /// suspension-free under `atomic_unit` on the real single-writer path
2418    /// (see docs guide) — a suspending closure would fail `block_on_sync`
2419    /// instead of returning `Ok`.
2420    #[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    /// ADR-099 D5 acceptance ("single-writer concurrency, mandatory"):
2490    /// session-mirror ingest must route through the shared writer task, not
2491    /// open its own standalone `BEGIN IMMEDIATE` (see docs guide for the
2492    /// queue-depth + occupier-parked-on-oneshot technique).
2493    #[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    /// ADR-099 revert-companion test: the pre-conversion shape (a closure
2596    /// issuing its own `BEGIN IMMEDIATE` inside `atomic_unit`) must fail
2597    /// deterministically with a nested-transaction error — proves the
2598    /// suspension-free assertions above are non-vacuous (see docs guide).
2599    #[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                // `atomic_unit` already has an open transaction around this
2613                // closure — issuing a second `BEGIN IMMEDIATE` here is
2614                // exactly the old `begin_tx`-shaped mistake this ADR
2615                // retires: a caller managing its own transaction control
2616                // inside a seam that already owns the transaction boundary.
2617                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}