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//! via a buffered, line-at-a-time reader bounded by an internal per-pass
5//! byte/event cap AND a per-line byte cap, parses complete lines using the
6//! parser selected by [`LineTailSource`] (mapped internally to
7//! [`MirrorSource`]), and writes the resulting bounded chunk to the session
8//! mirror tables in a single transaction.  A single call processes at most
9//! one bounded chunk — never the whole file at once — so the caller's
10//! polling loop advances the persisted cursor incrementally across multiple
11//! calls for large deltas.  It is safe to call repeatedly on the same file;
12//! `INSERT OR IGNORE` keyed by the event UUID ensures idempotency.
13//!
14//! No single line, complete or partial, is ever buffered past
15//! `MirrorLimits::max_line_bytes` (see `read_line_bounded`): a complete
16//! line over that cap is skipped with a `tracing::warn!` naming the file and
17//! byte offset, and the offset advances past it so ingestion never wedges on
18//! one oversized line. The pass cap is gated on at least one complete line
19//! (blank or not) having been consumed, and the cursor is persisted whenever
20//! a pass durably advances the offset even if it scanned zero parseable
21//! events — a long run of blank or oversized lines can no longer read to EOF
22//! unbounded, nor lose its cursor advance.
23//!
24//! A line that crosses `max_line_bytes` with no terminating `\n` yet — a
25//! still-growing file's in-progress final line, or a genuinely truncated /
26//! corrupt tail — is its own bounded case, distinct from the complete
27//! (terminated) oversized-line skip above: `read_line_bounded` reports
28//! `LineRead::OversizedUnterminated` as soon as one bounded read window
29//! crosses the cap without finding `\n`, instead of scanning onward to EOF
30//! looking for one. The cursor is intentionally left at that line's start
31//! (like an ordinary `Partial`), so the next poll — or the next daemon
32//! start — repeats the same bounded read rather than an unbounded tail
33//! scan; once the line eventually terminates (or the file stops growing and
34//! reaches true EOF mid-line), it resolves to the normal `Oversized`
35//! skip-and-advance path or stays a bounded `Partial`/`OversizedUnterminated`
36//! retry, never a full-file read in one call (PACKSESSION-AUD-003 round 2).
37
38use std::io::{BufRead, Seek, SeekFrom};
39use std::path::{Path, PathBuf};
40
41use chrono::Utc;
42use khive_runtime::{KhiveRuntime, RuntimeError};
43use khive_storage::types::{SqlStatement, SqlValue};
44use khive_storage::SqlWriter;
45
46use super::parse;
47
48/// The full ADR-080 mirror-source contract — the closed set of sources
49/// `sessions.source` can hold (`docs/adr/ADR-080-session-pack-oss-storage-mechanism.md`,
50/// "Mirror sources — closed set"). Adding a source requires amending that ADR
51/// section and this enum together.
52///
53/// This is a superset of [`LineTailSource`]: `ChatGptExport` ingests via
54/// whole-file re-parse (`mirror_chatgpt_export_file`), not the per-line
55/// dispatch `LineTailSource` selects, so it has no `LineTailSource` variant.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum MirrorSource {
58    /// Claude Code (`~/.claude/projects/<slug>/<uuid>.jsonl`).
59    ClaudeCode,
60    /// Codex CLI (`~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`).
61    Codex,
62    /// ChatGPT data export (`<exports dir>/**/conversations.json`).
63    ChatGptExport,
64}
65
66impl MirrorSource {
67    /// The string written to `sessions.source`.
68    pub fn as_str(self) -> &'static str {
69        match self {
70            MirrorSource::ClaudeCode => "claude_code",
71            MirrorSource::Codex => "codex",
72            MirrorSource::ChatGptExport => "chatgpt_export",
73        }
74    }
75}
76
77impl From<LineTailSource> for MirrorSource {
78    fn from(source: LineTailSource) -> Self {
79        match source {
80            LineTailSource::ClaudeCode => MirrorSource::ClaudeCode,
81            LineTailSource::Codex => MirrorSource::Codex,
82        }
83    }
84}
85
86/// Identifies which CLI produced the JSONL file being mirrored, for the
87/// purpose of selecting `mirror_file`'s per-line parser.
88///
89/// This is narrower than [`MirrorSource`]: it covers only the line-tail
90/// sources (append-only JSONL, tailed by byte offset). ChatGPT export
91/// ingestion is whole-file re-parse, not line-tail, so it has no variant
92/// here — see [`mirror_chatgpt_export_file`].
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum LineTailSource {
95    /// Claude Code (`~/.claude/projects/<slug>/<uuid>.jsonl`).
96    ClaudeCode,
97    /// Codex CLI (`~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`).
98    Codex,
99}
100
101/// Statistics returned by a single `mirror_file` call.
102#[derive(Debug, Default, Clone, PartialEq)]
103pub struct MirrorStats {
104    /// Number of new message rows inserted (0 if all were already present).
105    pub inserted: u64,
106    /// Number of complete lines scanned (including duplicates).
107    pub scanned: u64,
108    /// Byte offset advanced to (only past complete lines; partial trailing line excluded).
109    pub new_offset: u64,
110}
111
112/// Ceiling on bytes read per `mirror_file` call in production. Bounds worst-case
113/// memory use when a file has accumulated a very large delta (e.g. after daemon
114/// downtime or a multi-GB transcript).
115const MIRROR_MAX_BYTES_PER_PASS: usize = 8 * 1024 * 1024;
116
117/// Ceiling on parsed events collected per `mirror_file` call in production.
118const MIRROR_MAX_EVENTS_PER_PASS: usize = 1024;
119
120/// Hard ceiling on a single JSONL line's buffered size in production. This is
121/// enforced by `read_line_bounded` itself (never appended to past this many
122/// bytes), independently of `max_bytes_per_pass` — a single oversized line
123/// must not be able to allocate past this bound even as the very first line
124/// of a pass (PACKSESSION-AUD-003).
125const MIRROR_MAX_LINE_BYTES: usize = MIRROR_MAX_BYTES_PER_PASS;
126
127/// Per-call caps on how much of a file's delta `mirror_file` will read and
128/// parse before writing a bounded chunk. Production always uses
129/// [`MirrorLimits::production`]; tests use a much smaller cap to force
130/// multi-pass behavior without giant fixtures.
131#[derive(Clone, Copy, Debug)]
132struct MirrorLimits {
133    max_bytes_per_pass: usize,
134    max_events_per_pass: usize,
135    max_line_bytes: usize,
136}
137
138impl MirrorLimits {
139    fn production() -> Self {
140        Self {
141            max_bytes_per_pass: MIRROR_MAX_BYTES_PER_PASS,
142            max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
143            max_line_bytes: MIRROR_MAX_LINE_BYTES,
144        }
145    }
146}
147
148/// Read new bytes of `path` starting at `start_offset`, parse complete lines
149/// using the parser selected by `source`, and upsert them idempotently into the
150/// session mirror tables.
151///
152/// For `LineTailSource::Codex`, `codex_session_id` must be the session UUID
153/// derived from the filename; it is used both to key the `sessions` row and to
154/// synthesise per-line event UUIDs (`"{session_id}:{abs_byte_offset}"`).
155/// For `LineTailSource::ClaudeCode`, `codex_session_id` is ignored (the session
156/// UUID is embedded in each line).
157///
158/// Returns stats including the advanced byte offset.  A partial trailing line
159/// (no terminating `\n`) is left for the next poll — `new_offset` is set to
160/// the byte after the last complete `\n`.
161///
162/// One bad file or one bad line does NOT kill the loop: per-file errors propagate
163/// to the caller (the service loop logs and continues); per-line parse failures
164/// are silently skipped (the parser returns `None`).
165pub async fn mirror_file(
166    runtime: &KhiveRuntime,
167    path: &Path,
168    start_offset: u64,
169    source: LineTailSource,
170    codex_session_id: Option<&str>,
171) -> Result<MirrorStats, RuntimeError> {
172    mirror_file_with_limits(
173        runtime,
174        path,
175        start_offset,
176        source,
177        codex_session_id,
178        MirrorLimits::production(),
179    )
180    .await
181}
182
183/// A single bounded read pass: at most `limits.max_bytes_per_pass` bytes and
184/// `limits.max_events_per_pass` parsed events, always stopping on a complete
185/// line boundary.
186struct MirrorChunk {
187    events: Vec<parse::ParsedEvent>,
188    /// Complete, nonblank, non-oversized lines that were handed to the
189    /// per-source parser (whether or not the parser returned an event).
190    scanned: u64,
191    new_offset: u64,
192}
193
194/// Outcome of `read_line_bounded` for one line.
195#[derive(Debug)]
196enum LineRead {
197    /// EOF with nothing read at all.
198    Eof,
199    /// EOF reached before a terminating `\n`: an incomplete trailing line,
200    /// left for the next pass. No bytes are considered consumed by the
201    /// caller (the offset does not advance), regardless of how large the
202    /// partial line has already grown.
203    Partial,
204    /// A complete line (through the terminating `\n`) fit within
205    /// `max_line_bytes`; `buf` holds the full line including the `\n`.
206    /// `bytes` is the total bytes consumed from the reader, used to advance
207    /// the caller's byte offset.
208    Complete { bytes: usize },
209    /// A complete line (through the terminating `\n`) exceeded
210    /// `max_line_bytes` before the newline was found. `buf` was never fully
211    /// populated — bytes past the cap were scanned for `\n` and discarded
212    /// without buffering — so the caller must skip it, not parse `buf`.
213    /// `bytes` is the total bytes consumed from the reader.
214    Oversized { bytes: usize },
215    /// The line has already exceeded `max_line_bytes` and no terminating
216    /// `\n` has been found yet, but this is NOT end-of-file — there may be
217    /// more bytes (a still-growing file) or a genuinely unterminated tail.
218    /// Unlike `Oversized`, the caller must not advance past it: `bytes` is
219    /// reported for logging only, and the reader is intentionally not
220    /// exhausted any further this call. This is the hard bound behind
221    /// PACKSESSION-AUD-003 round 2 — the loop below returns as soon as one
222    /// `fill_buf` window crosses the cap without a `\n`, instead of looping
223    /// `fill_buf`/`consume` all the way to EOF searching for a terminator
224    /// that may never come.
225    OversizedUnterminated { bytes: usize },
226}
227
228/// Read one line from `reader` into `buf`, never buffering more than
229/// `max_line_bytes` regardless of how long the underlying line turns out to
230/// be.
231///
232/// This is the hard bound behind PACKSESSION-AUD-003: `BufRead::read_until`
233/// alone appends an entire line to its buffer before any cap check can run,
234/// so a single arbitrarily large complete line (or a line that starts below
235/// a per-pass threshold and ends far beyond it) can still allocate without
236/// limit before the calling loop ever inspects it. Reading via `fill_buf`/
237/// `consume` directly means a line longer than `max_line_bytes` is never
238/// appended to `buf` past the cap — bytes beyond it are scanned for `\n` and
239/// dropped immediately, bounding this function's own resident memory to
240/// `max_line_bytes` (plus one `BufRead` internal buffer) no matter how long
241/// the real line is.
242///
243/// The same bound applies to the number of bytes *read* per call, not just
244/// buffered (PACKSESSION-AUD-003 round 2): once a line has crossed
245/// `max_line_bytes` without a terminating `\n`, the very next `fill_buf`
246/// window that still has no `\n` returns `OversizedUnterminated` immediately
247/// rather than looping `fill_buf`/`consume` onward in search of one. A line
248/// that is oversized but DOES terminate within that same window still comes
249/// back as `Oversized` (the existing skip-and-advance path) — only the
250/// no-terminator-in-this-window case is capped early. This means one call to
251/// `read_line_bounded` never reads more than `max_line_bytes` plus one
252/// `BufRead` internal buffer for a line with no discoverable `\n`, whether
253/// that line is still growing (append-in-progress) or truly unterminated at
254/// EOF — instead of scanning the remainder of the file (or forever, on a
255/// still-growing file) in a single pass.
256fn read_line_bounded(
257    reader: &mut impl BufRead,
258    buf: &mut Vec<u8>,
259    max_line_bytes: usize,
260) -> std::io::Result<LineRead> {
261    let mut total: usize = 0;
262    let mut oversized = false;
263
264    loop {
265        let available = reader.fill_buf()?;
266        if available.is_empty() {
267            return Ok(if total == 0 {
268                LineRead::Eof
269            } else {
270                LineRead::Partial
271            });
272        }
273
274        let newline_pos = available.iter().position(|&b| b == b'\n');
275        let take = newline_pos.map_or(available.len(), |pos| pos + 1);
276
277        if !oversized {
278            if total + take > max_line_bytes {
279                oversized = true;
280            } else {
281                buf.extend_from_slice(&available[..take]);
282            }
283        }
284
285        total += take;
286        reader.consume(take);
287
288        if newline_pos.is_some() {
289            return Ok(if oversized {
290                LineRead::Oversized { bytes: total }
291            } else {
292                LineRead::Complete { bytes: total }
293            });
294        }
295
296        if oversized {
297            // Already over the cap and this fill_buf window had no `\n`:
298            // stop here rather than looping onward toward EOF (or forever,
299            // if the file keeps growing). See the PACKSESSION-AUD-003
300            // round-2 bound above.
301            return Ok(LineRead::OversizedUnterminated { bytes: total });
302        }
303        // No `\n` in this fill_buf window yet, and still under the cap;
304        // loop for more data, buffering normally.
305    }
306}
307
308/// Read at most one bounded chunk of `path` starting at `start_offset`, one
309/// complete line at a time via a buffered reader — never allocating more than
310/// `limits.max_line_bytes` for any single line. A partial trailing line (no
311/// terminating `\n`) is left for the next call.
312///
313/// A complete line whose buffered size would exceed `limits.max_line_bytes`
314/// is rejected outright: it is never parsed, its bytes are counted and the
315/// offset advances past it (so ingestion does not wedge on it forever), and
316/// a `tracing::warn!` names the file and starting byte offset so an operator
317/// can find and inspect it (PACKSESSION-AUD-003 — no silent coercion).
318fn read_bounded_chunk(
319    path: &Path,
320    start_offset: u64,
321    source: LineTailSource,
322    codex_session_id: Option<&str>,
323    limits: MirrorLimits,
324) -> std::io::Result<MirrorChunk> {
325    let mut file = std::fs::File::open(path)?;
326    let file_len = file.metadata()?.len();
327    if start_offset >= file_len {
328        return Ok(MirrorChunk {
329            events: Vec::new(),
330            scanned: 0,
331            new_offset: start_offset,
332        });
333    }
334
335    file.seek(SeekFrom::Start(start_offset))?;
336    let mut reader = std::io::BufReader::new(file);
337    let mut line = Vec::new();
338    let mut events = Vec::new();
339    let mut scanned: u64 = 0;
340    let mut lines_consumed: u64 = 0;
341    let mut new_offset = start_offset;
342    let mut bytes_this_pass: usize = 0;
343
344    loop {
345        if lines_consumed > 0
346            && (bytes_this_pass >= limits.max_bytes_per_pass
347                || events.len() >= limits.max_events_per_pass)
348        {
349            break;
350        }
351
352        line.clear();
353        let line_offset = new_offset;
354
355        match read_line_bounded(&mut reader, &mut line, limits.max_line_bytes)? {
356            LineRead::Eof => break,
357            LineRead::Partial => break, // leave partial trailing line for next pass
358            LineRead::OversizedUnterminated { bytes } => {
359                // Already over max_line_bytes with no `\n` found in this
360                // bounded read (see `read_line_bounded`'s round-2 bound):
361                // do NOT advance new_offset past line_offset. The next call
362                // re-reads from the same line_offset and is bounded the
363                // same way — cheap and repeatable, whether the file is
364                // still growing (a later pass will eventually see the
365                // terminator and fall into the `Oversized` skip-and-advance
366                // arm below) or genuinely corrupt/truncated (every later
367                // poll or daemon restart repeats this same bounded read,
368                // never the unbounded tail scan PACKSESSION-AUD-003 flagged).
369                tracing::warn!(
370                    path = %path.display(),
371                    offset = line_offset,
372                    line_bytes = bytes,
373                    max_line_bytes = limits.max_line_bytes,
374                    "session mirror: oversized JSONL line has no terminator in this bounded read; \
375                     leaving cursor at line start for a bounded retry"
376                );
377                break;
378            }
379            LineRead::Oversized { bytes } => {
380                tracing::warn!(
381                    path = %path.display(),
382                    offset = line_offset,
383                    line_bytes = bytes,
384                    max_line_bytes = limits.max_line_bytes,
385                    "session mirror: skipping oversized JSONL line"
386                );
387                new_offset += bytes as u64;
388                bytes_this_pass += bytes;
389                lines_consumed += 1;
390            }
391            LineRead::Complete { bytes } => {
392                new_offset += bytes as u64;
393                bytes_this_pass += bytes;
394                lines_consumed += 1;
395
396                let raw = String::from_utf8_lossy(&line[..line.len() - 1]);
397                if raw.is_empty() {
398                    continue; // blank line: bytes consumed, but not counted as scanned
399                }
400
401                match source {
402                    LineTailSource::ClaudeCode => {
403                        if let Some(ev) = parse::parse_cc_line(&raw) {
404                            events.push(ev);
405                        }
406                    }
407                    LineTailSource::Codex => {
408                        let sid = codex_session_id.unwrap_or("");
409                        if let Some(ev) = parse::parse_codex_line(&raw, sid, line_offset) {
410                            events.push(ev);
411                        }
412                    }
413                }
414                scanned += 1;
415            }
416        }
417    }
418
419    Ok(MirrorChunk {
420        events,
421        scanned,
422        new_offset,
423    })
424}
425
426/// Read, parse, and write one bounded chunk starting at `start_offset`.
427async fn mirror_file_with_limits(
428    runtime: &KhiveRuntime,
429    path: &Path,
430    start_offset: u64,
431    source: LineTailSource,
432    codex_session_id: Option<&str>,
433    limits: MirrorLimits,
434) -> Result<MirrorStats, RuntimeError> {
435    let chunk =
436        read_bounded_chunk(path, start_offset, source, codex_session_id, limits).map_err(|e| {
437            RuntimeError::Internal(format!(
438                "mirror_file: failed to read {:?} at offset {start_offset}: {e}",
439                path
440            ))
441        })?;
442
443    if chunk.new_offset == start_offset {
444        // Nothing was consumed this pass (EOF, or only a partial trailing
445        // line was seen) — there is no advanced cursor to persist.
446        return Ok(MirrorStats {
447            inserted: 0,
448            scanned: 0,
449            new_offset: chunk.new_offset,
450        });
451    }
452
453    if chunk.events.is_empty() {
454        // Apply cursor update even when there are no parseable events — e.g.
455        // a chunk made entirely of blank lines, unparseable lines, or
456        // skipped oversized lines — so we don't re-read the same bytes on
457        // the next call. `chunk.new_offset > start_offset` here (checked
458        // above), so real bytes were durably consumed even though
459        // `chunk.scanned` may be 0. A failure here must propagate — silently
460        // swallowing it would let the cursor and the already-consumed bytes
461        // drift apart.
462        write_cursor_only(runtime, path, &None, chunk.new_offset).await?;
463        return Ok(MirrorStats {
464            inserted: 0,
465            scanned: chunk.scanned,
466            new_offset: chunk.new_offset,
467        });
468    }
469
470    write_events_and_cursor(
471        runtime,
472        path,
473        MirrorSource::from(source).as_str(),
474        &chunk.events,
475        chunk.scanned,
476        chunk.new_offset,
477    )
478    .await
479}
480
481/// Default ceiling on the byte length of a ChatGPT export `conversations.json`
482/// file read in one [`mirror_chatgpt_export_file`] pass. Overridable via
483/// `KHIVE_MIRROR_CHATGPT_MAX_BYTES` (see `chatgpt_max_bytes`).
484///
485/// Unlike the JSONL line-tail sources, a ChatGPT export has no incremental
486/// "new bytes" boundary — it is always read and parsed whole (see the
487/// function doc below) — so this is a ceiling on the *entire file*, not a
488/// per-pass delta. An export over this size is skipped for that pass
489/// (loudly logged via `tracing::warn!`, never a crash or an unbounded
490/// `read_to_string`), and the cursor is left untouched so the oversized
491/// source keeps being retried — and re-warned — on every later tick instead
492/// of silently dropping forever (PACKSESSION-AUD-003).
493const DEFAULT_CHATGPT_MAX_BYTES: u64 = 256 * 1024 * 1024;
494
495/// Resolve the ChatGPT export size ceiling from `KHIVE_MIRROR_CHATGPT_MAX_BYTES`,
496/// falling back to [`DEFAULT_CHATGPT_MAX_BYTES`] for missing, non-numeric, or
497/// zero values (a zero ceiling would skip every export unconditionally,
498/// which is never useful, so it is treated the same as unset).
499fn chatgpt_max_bytes() -> u64 {
500    std::env::var("KHIVE_MIRROR_CHATGPT_MAX_BYTES")
501        .ok()
502        .and_then(|v| v.parse::<u64>().ok())
503        .filter(|&n| n > 0)
504        .unwrap_or(DEFAULT_CHATGPT_MAX_BYTES)
505}
506
507/// Read the whole ChatGPT export `conversations.json` at `path`, parse every
508/// conversation's mapping tree via [`parse::parse_chatgpt_export`], and upsert
509/// every message-bearing event idempotently into the session mirror tables in
510/// a single transaction.
511///
512/// Unlike `mirror_file` (append-only line-tail), a ChatGPT export is a single
513/// static JSON array with no stable "new bytes" boundary to tail, so this
514/// function always re-reads and re-parses the whole file. `start_offset` is
515/// used only as a cheap re-poll guard: if the file has not grown past it,
516/// nothing is read or parsed. `new_offset` is set to the whole file's byte
517/// length only after a successful parse and commit — any IO, parse, or DB
518/// error leaves the persisted cursor untouched, so a partially-downloaded
519/// export is retried whole on the next tick, never half-consumed.
520///
521/// Before reading, the file is checked against `chatgpt_max_bytes`: an
522/// export over that ceiling is skipped (warn-logged) without ever calling
523/// `read_to_string`, so a very large export cannot materialize its full
524/// content (and, downstream, a full `Vec` of parsed events) in one pass.
525pub async fn mirror_chatgpt_export_file(
526    runtime: &KhiveRuntime,
527    path: &Path,
528    start_offset: u64,
529) -> Result<MirrorStats, RuntimeError> {
530    mirror_chatgpt_export_file_with_max_bytes(runtime, path, start_offset, chatgpt_max_bytes())
531        .await
532}
533
534/// Implementation behind [`mirror_chatgpt_export_file`], taking an explicit
535/// `max_bytes` ceiling so tests can exercise the oversized-skip path without
536/// a giant fixture or racing on process-global environment variables.
537async fn mirror_chatgpt_export_file_with_max_bytes(
538    runtime: &KhiveRuntime,
539    path: &Path,
540    start_offset: u64,
541    max_bytes: u64,
542) -> Result<MirrorStats, RuntimeError> {
543    let file_len = std::fs::metadata(path).map(|m| m.len()).map_err(|e| {
544        RuntimeError::Internal(format!(
545            "mirror_chatgpt_export_file: failed to stat {path:?}: {e}"
546        ))
547    })?;
548
549    if file_len <= start_offset {
550        return Ok(MirrorStats {
551            inserted: 0,
552            scanned: 0,
553            new_offset: start_offset,
554        });
555    }
556
557    if file_len > max_bytes {
558        tracing::warn!(
559            path = %path.display(),
560            file_bytes = file_len,
561            max_bytes,
562            "session mirror: skipping oversized ChatGPT export (exceeds KHIVE_MIRROR_CHATGPT_MAX_BYTES)"
563        );
564        return Ok(MirrorStats {
565            inserted: 0,
566            scanned: 0,
567            new_offset: start_offset,
568        });
569    }
570
571    let content = std::fs::read_to_string(path).map_err(|e| {
572        RuntimeError::Internal(format!(
573            "mirror_chatgpt_export_file: failed to read {path:?}: {e}"
574        ))
575    })?;
576
577    let events = parse::parse_chatgpt_export(&content).ok_or_else(|| {
578        RuntimeError::Internal(format!(
579            "mirror_chatgpt_export_file: {path:?} is not a valid ChatGPT export (expected a top-level JSON array)"
580        ))
581    })?;
582
583    let scanned = events.len() as u64;
584
585    write_events_and_cursor(
586        runtime,
587        path,
588        MirrorSource::ChatGptExport.as_str(),
589        &events,
590        scanned,
591        file_len,
592    )
593    .await
594}
595
596/// Upsert `events` and the mirror cursor for `path` in one transaction.
597///
598/// Shared by `mirror_file`'s eventful line-tail path and
599/// `mirror_chatgpt_export_file`'s whole-file path, so the session/message row
600/// construction and cursor semantics (create-only sessions, `INSERT OR
601/// IGNORE` message dedup, monotonic `last_seen_at`, cursor advances only on
602/// success) live in exactly one place.
603async fn write_events_and_cursor(
604    runtime: &KhiveRuntime,
605    path: &Path,
606    source_value: &'static str,
607    events: &[parse::ParsedEvent],
608    scanned: u64,
609    new_offset: u64,
610) -> Result<MirrorStats, RuntimeError> {
611    let now_us = Utc::now().timestamp_micros();
612    let sql = runtime.sql();
613
614    // ADR-099 D5: this closure is verified suspension-free (it drives only
615    // `writer` with inline-built `SqlStatement`s — session/message INSERTs,
616    // the count refresh, and the cursor UPDATE — with no embedding, no ANN
617    // warming, and no other `await` on an external service), so handing it
618    // to `atomic_unit` satisfies the atomic-unit suspend-free invariant
619    // (`SqlAccess::atomic_unit`'s doc comment) identically on the
620    // single-writer and flag-off paths. This replaces the standalone
621    // `begin_tx` this function used before ADR-099: the whole sequence
622    // still commits once or rolls back as one unit, but no longer opens its
623    // own connection outside the writer task.
624    let events_owned: Vec<parse::ParsedEvent> = events.to_vec();
625    let path_owned: PathBuf = path.to_path_buf();
626
627    let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
628        Box::pin(async move {
629            write_events_and_cursor_on_writer(
630                writer,
631                &path_owned,
632                source_value,
633                &events_owned,
634                scanned,
635                new_offset,
636                now_us,
637            )
638            .await
639            .map(|stats| Box::new(stats) as Box<dyn std::any::Any + Send>)
640            .map_err(|e| {
641                khive_storage::StorageError::driver(
642                    khive_storage::StorageCapability::Sql,
643                    "session_mirror_write_events_and_cursor",
644                    e,
645                )
646            })
647        })
648    });
649
650    let boxed = sql
651        .atomic_unit(op)
652        .await
653        .map_err(|e| RuntimeError::Internal(format!("mirror: atomic_unit: {e}")))?;
654
655    Ok(*boxed.downcast::<MirrorStats>().unwrap_or_else(|_| {
656        panic!("atomic_unit op for write_events_and_cursor must return MirrorStats")
657    }))
658}
659
660/// The synchronous-DML body of `write_events_and_cursor`, run inside one
661/// `atomic_unit` closure (see that function's doc comment for the
662/// suspension-free justification). Takes a plain `&mut dyn SqlWriter`
663/// (not `&mut dyn SqlTransaction`) because `atomic_unit` owns the
664/// transaction boundary entirely — this function must not, and does not,
665/// issue its own `BEGIN`/`COMMIT`/`ROLLBACK`.
666async fn write_events_and_cursor_on_writer(
667    writer: &mut dyn SqlWriter,
668    path: &Path,
669    source_value: &'static str,
670    events: &[parse::ParsedEvent],
671    scanned: u64,
672    new_offset: u64,
673    now_us: i64,
674) -> khive_storage::types::StorageResult<MirrorStats> {
675    let mut inserted: u64 = 0;
676    let mut last_session_id: Option<String> = None;
677
678    for ev in events {
679        let created_at = if ev.created_at_micros != 0 {
680            ev.created_at_micros
681        } else {
682            now_us
683        };
684
685        // ── sessions row: create-only ─────────────────────────────────────────
686        //
687        // First sight of a session creates the row (first_seen_at = last_seen_at =
688        // this event's timestamp). Replays are a cheap no-op (`DO NOTHING`), so a
689        // pass that inserts no new messages writes no session metadata at all —
690        // strict replay idempotency. `last_seen_at` is advanced below, but only
691        // when a genuinely new message lands.
692        writer
693            .execute(SqlStatement {
694                sql: format!(
695                    "INSERT INTO sessions \
696                      (id, provider_session_id, source, cwd, git_branch, slug, \
697                       message_count, first_seen_at, last_seen_at, namespace) \
698                      VALUES(?1, ?1, '{}', ?2, ?3, ?4, 0, ?5, ?5, 'local') \
699                      ON CONFLICT(id) DO NOTHING",
700                    source_value
701                ),
702                params: vec![
703                    SqlValue::Text(ev.session_id.clone()),
704                    ev.cwd
705                        .as_deref()
706                        .map(|s| SqlValue::Text(s.to_string()))
707                        .unwrap_or(SqlValue::Null),
708                    ev.git_branch
709                        .as_deref()
710                        .map(|s| SqlValue::Text(s.to_string()))
711                        .unwrap_or(SqlValue::Null),
712                    ev.slug
713                        .as_deref()
714                        .map(|s| SqlValue::Text(s.to_string()))
715                        .unwrap_or(SqlValue::Null),
716                    SqlValue::Integer(created_at),
717                ],
718                label: Some("session_mirror_create_session".into()),
719            })
720            .await
721            .map_err(|e| {
722                khive_storage::StorageError::driver(
723                    khive_storage::StorageCapability::Sql,
724                    "mirror: session create",
725                    e,
726                )
727            })?;
728
729        // ── session_messages insert (idempotent) ──────────────────────────────
730        let affected = writer
731            .execute(SqlStatement {
732                sql: "INSERT OR IGNORE INTO session_messages \
733                      (id, session_id, seq, parent_uuid, is_sidechain, role, \
734                       msg_type, text, raw, created_at, namespace) \
735                      VALUES(?1, ?2, \
736                        (SELECT COALESCE(MAX(seq),-1)+1 FROM session_messages WHERE session_id=?2), \
737                        ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'local')"
738                    .into(),
739                params: vec![
740                    SqlValue::Text(ev.uuid.clone()),
741                    SqlValue::Text(ev.session_id.clone()),
742                    ev.parent_uuid
743                        .as_deref()
744                        .map(|s| SqlValue::Text(s.to_string()))
745                        .unwrap_or(SqlValue::Null),
746                    SqlValue::Integer(i64::from(ev.is_sidechain)),
747                    ev.role
748                        .as_deref()
749                        .map(|s| SqlValue::Text(s.to_string()))
750                        .unwrap_or(SqlValue::Null),
751                    SqlValue::Text(ev.msg_type.clone()),
752                    ev.text
753                        .as_deref()
754                        .map(|s| SqlValue::Text(s.to_string()))
755                        .unwrap_or(SqlValue::Null),
756                    SqlValue::Text(ev.raw.clone()),
757                    SqlValue::Integer(created_at),
758                ],
759                label: Some("session_mirror_insert_message".into()),
760            })
761            .await
762            .map_err(|e| {
763                khive_storage::StorageError::driver(
764                    khive_storage::StorageCapability::Sql,
765                    "mirror: message insert",
766                    e,
767                )
768            })?;
769
770        // ── advance session metadata ONLY when a new message landed ────────────
771        //
772        // Keeps `last_seen_at` monotonic (`MAX`) so a timestamp-missing replay
773        // (whose `created_at` fell back to `now_us`) cannot move it forward, and
774        // backfills metadata that may have been NULL at create time. A pure
775        // replay (`affected == 0`) touches nothing.
776        if affected > 0 {
777            writer
778                .execute(SqlStatement {
779                    sql: "UPDATE sessions SET \
780                            last_seen_at=MAX(last_seen_at, ?2), \
781                            cwd=COALESCE(cwd, ?3), \
782                            git_branch=COALESCE(git_branch, ?4), \
783                            slug=COALESCE(slug, ?5) \
784                          WHERE id=?1"
785                        .into(),
786                    params: vec![
787                        SqlValue::Text(ev.session_id.clone()),
788                        SqlValue::Integer(created_at),
789                        ev.cwd
790                            .as_deref()
791                            .map(|s| SqlValue::Text(s.to_string()))
792                            .unwrap_or(SqlValue::Null),
793                        ev.git_branch
794                            .as_deref()
795                            .map(|s| SqlValue::Text(s.to_string()))
796                            .unwrap_or(SqlValue::Null),
797                        ev.slug
798                            .as_deref()
799                            .map(|s| SqlValue::Text(s.to_string()))
800                            .unwrap_or(SqlValue::Null),
801                    ],
802                    label: Some("session_mirror_touch_session".into()),
803                })
804                .await
805                .map_err(|e| {
806                    khive_storage::StorageError::driver(
807                        khive_storage::StorageCapability::Sql,
808                        "mirror: session touch",
809                        e,
810                    )
811                })?;
812        }
813
814        inserted += affected;
815        last_session_id = Some(ev.session_id.clone());
816    }
817
818    // ── refresh message_count for each distinct session ───────────────────────
819    //
820    // In practice one file maps to one session_id, but we refresh every
821    // session_id we touched to stay correct even if that changes. Skipped
822    // entirely on a pure replay (`inserted == 0`): the counts cannot have
823    // changed, so writing them would be needless churn.
824    if inserted > 0 {
825        let mut seen_sessions: Vec<String> = events
826            .iter()
827            .map(|e| e.session_id.clone())
828            .collect::<std::collections::HashSet<_>>()
829            .into_iter()
830            .collect();
831        seen_sessions.sort(); // deterministic order for tests
832
833        for sid in &seen_sessions {
834            writer
835                .execute(SqlStatement {
836                    sql: "UPDATE sessions SET message_count=\
837                          (SELECT COUNT(*) FROM session_messages WHERE session_id=?1) \
838                          WHERE id=?1"
839                        .into(),
840                    params: vec![SqlValue::Text(sid.clone())],
841                    label: Some("session_mirror_refresh_count".into()),
842                })
843                .await
844                .map_err(|e| {
845                    khive_storage::StorageError::driver(
846                        khive_storage::StorageCapability::Sql,
847                        "mirror: count refresh",
848                        e,
849                    )
850                })?;
851        }
852    }
853
854    upsert_cursor_on_writer(writer, path, last_session_id.as_deref(), new_offset, now_us).await?;
855
856    // ── commit ────────────────────────────────────────────────────────────────
857    //
858    // No explicit COMMIT here: `atomic_unit` owns the transaction boundary
859    // entirely (see this function's doc comment) and commits once this
860    // closure returns `Ok`, or rolls back the whole unit on `Err` — the
861    // same all-or-nothing contract the old `begin_tx`/`tx.commit()` shape
862    // gave, now provided by the seam instead of a manual transaction.
863    Ok(MirrorStats {
864        inserted,
865        scanned,
866        new_offset,
867    })
868}
869
870/// Upsert the `session_mirror_cursor` row for `path` inside the open
871/// `atomic_unit` transaction. Takes `&mut dyn SqlWriter` (see
872/// `write_events_and_cursor_on_writer`'s doc comment) — it issues only the
873/// one cursor DML statement, no transaction control of its own.
874async fn upsert_cursor_on_writer(
875    writer: &mut dyn SqlWriter,
876    path: &Path,
877    session_id: Option<&str>,
878    new_offset: u64,
879    now_us: i64,
880) -> khive_storage::types::StorageResult<()> {
881    let path_str = path.to_string_lossy().into_owned();
882    writer
883        .execute(SqlStatement {
884            sql:
885                "INSERT INTO session_mirror_cursor(file_path, session_id, byte_offset, updated_at) \
886              VALUES(?1, ?2, ?3, ?4) \
887              ON CONFLICT(file_path) DO UPDATE SET \
888                session_id=excluded.session_id, \
889                byte_offset=excluded.byte_offset, \
890                updated_at=excluded.updated_at"
891                    .into(),
892            params: vec![
893                SqlValue::Text(path_str),
894                session_id
895                    .map(|s| SqlValue::Text(s.to_string()))
896                    .unwrap_or(SqlValue::Null),
897                SqlValue::Integer(new_offset as i64),
898                SqlValue::Integer(now_us),
899            ],
900            label: Some("session_mirror_cursor_upsert".into()),
901        })
902        .await
903        .map_err(|e| {
904            khive_storage::StorageError::driver(
905                khive_storage::StorageCapability::Sql,
906                "mirror: cursor upsert",
907                e,
908            )
909        })?;
910    Ok(())
911}
912
913/// Write only the cursor row (no events).  Used when there are no parseable
914/// events so the offset still advances past blank/unparseable content.
915async fn write_cursor_only(
916    runtime: &KhiveRuntime,
917    path: &Path,
918    session_id: &Option<String>,
919    new_offset: u64,
920) -> Result<(), RuntimeError> {
921    let now_us = Utc::now().timestamp_micros();
922    let path_str = path.to_string_lossy().into_owned();
923    let sql = runtime.sql();
924    let mut w = sql
925        .writer()
926        .await
927        .map_err(|e| RuntimeError::Internal(format!("mirror_file: cursor writer: {e}")))?;
928    w.execute(SqlStatement {
929        sql: "INSERT INTO session_mirror_cursor(file_path, session_id, byte_offset, updated_at) \
930              VALUES(?1, ?2, ?3, ?4) \
931              ON CONFLICT(file_path) DO UPDATE SET \
932                session_id=COALESCE(excluded.session_id, session_mirror_cursor.session_id), \
933                byte_offset=excluded.byte_offset, \
934                updated_at=excluded.updated_at"
935            .into(),
936        params: vec![
937            SqlValue::Text(path_str),
938            session_id
939                .as_deref()
940                .map(|s| SqlValue::Text(s.to_string()))
941                .unwrap_or(SqlValue::Null),
942            SqlValue::Integer(new_offset as i64),
943            SqlValue::Integer(now_us),
944        ],
945        label: Some("session_mirror_cursor_only".into()),
946    })
947    .await
948    .map_err(|e| RuntimeError::Internal(format!("mirror_file: cursor write: {e}")))?;
949    Ok(())
950}
951
952// ── integration tests ─────────────────────────────────────────────────────────
953
954#[cfg(test)]
955mod tests {
956    use std::io::Write;
957    use std::sync::Arc;
958
959    use khive_runtime::{
960        AllowAllGate, BackendId, KhiveRuntime, Namespace, RuntimeConfig, RuntimeError,
961    };
962    use khive_storage::types::{SqlStatement, SqlValue};
963    use tempfile::{NamedTempFile, TempDir};
964
965    use super::*;
966    use crate::vocab::SESSION_SCHEMA_PLAN_STMTS;
967
968    /// Build a file-backed runtime and apply the session schema.
969    ///
970    /// File-backed so tests exercise the same `atomic_unit` single-writer
971    /// path (`mirror_file` → `write_events_and_cursor` → `atomic_unit`,
972    /// ADR-099 D5) production runs against — an in-memory pool would take
973    /// `atomic_unit`'s pool-backed manual-transaction branch instead. The
974    /// caller must keep the returned `TempDir` alive for the test.
975    async fn setup() -> (KhiveRuntime, TempDir) {
976        let dir = TempDir::new().expect("tempdir");
977        let db_path = dir.path().join("test.db");
978        let rt = KhiveRuntime::new(RuntimeConfig {
979            db_path: Some(db_path),
980            default_namespace: Namespace::local(),
981            embedding_model: None,
982            additional_embedding_models: vec![],
983            gate: Arc::new(AllowAllGate),
984            packs: vec!["kg".to_string()],
985            backend_id: BackendId::main(),
986            brain_profile: None,
987            visible_namespaces: vec![],
988            allowed_outbound_namespaces: vec![],
989            actor_id: None,
990        })
991        .expect("file-backed runtime");
992        apply_session_schema(&rt).await;
993        (rt, dir)
994    }
995
996    async fn apply_session_schema(rt: &KhiveRuntime) {
997        let sql = rt.sql();
998        let mut w = sql.writer().await.expect("writer");
999        for stmt in &SESSION_SCHEMA_PLAN_STMTS {
1000            w.execute_script(stmt.to_string())
1001                .await
1002                .expect("schema stmt");
1003        }
1004        // w dropped here — releases the writer connection.
1005    }
1006
1007    /// Count rows in a table.
1008    async fn count_rows(rt: &KhiveRuntime, table: &str) -> i64 {
1009        let sql = rt.sql();
1010        let mut r = sql.reader().await.expect("reader");
1011        let row = r
1012            .query_row(SqlStatement {
1013                sql: format!("SELECT COUNT(*) FROM {table}"),
1014                params: vec![],
1015                label: None,
1016            })
1017            .await
1018            .expect("count query")
1019            .expect("count row");
1020        match row.columns.first().map(|c| &c.value) {
1021            Some(SqlValue::Integer(n)) => *n,
1022            _ => 0,
1023        }
1024    }
1025
1026    /// Retrieve the stored byte_offset for a file path.
1027    async fn cursor_offset(rt: &KhiveRuntime, path_str: &str) -> Option<i64> {
1028        let sql = rt.sql();
1029        let mut r = sql.reader().await.expect("reader");
1030        let row = r
1031            .query_row(SqlStatement {
1032                sql: "SELECT byte_offset FROM session_mirror_cursor WHERE file_path=?1".into(),
1033                params: vec![SqlValue::Text(path_str.to_string())],
1034                label: None,
1035            })
1036            .await
1037            .expect("cursor query")?;
1038        match row.columns.first().map(|c| &c.value) {
1039            Some(SqlValue::Integer(n)) => Some(*n),
1040            _ => None,
1041        }
1042    }
1043
1044    fn user_line(uuid: &str, session_id: &str, text: &str) -> String {
1045        format!(
1046            r#"{{"uuid":"{uuid}","sessionId":"{session_id}","type":"user","timestamp":"2026-06-29T10:00:00Z","message":{{"role":"user","content":"{text}"}}}}"#
1047        )
1048    }
1049
1050    /// A user line with NO `timestamp` field — `created_at` falls back to `now_us`.
1051    fn user_line_no_ts(uuid: &str, session_id: &str, text: &str) -> String {
1052        format!(
1053            r#"{{"uuid":"{uuid}","sessionId":"{session_id}","type":"user","message":{{"role":"user","content":"{text}"}}}}"#
1054        )
1055    }
1056
1057    /// Retrieve the stored `last_seen_at` for a session id.
1058    async fn last_seen_at(rt: &KhiveRuntime, session_id: &str) -> Option<i64> {
1059        let sql = rt.sql();
1060        let mut r = sql.reader().await.expect("reader");
1061        let row = r
1062            .query_row(SqlStatement {
1063                sql: "SELECT last_seen_at FROM sessions WHERE id=?1".into(),
1064                params: vec![SqlValue::Text(session_id.to_string())],
1065                label: None,
1066            })
1067            .await
1068            .expect("last_seen query")?;
1069        match row.columns.first().map(|c| &c.value) {
1070            Some(SqlValue::Integer(n)) => Some(*n),
1071            _ => None,
1072        }
1073    }
1074
1075    #[tokio::test]
1076    async fn test_mirror_three_lines_and_idempotency() {
1077        let (rt, _dir) = setup().await;
1078
1079        // Build a fixture JSONL with 3 lines, all ending in '\n'.
1080        let line1 = user_line("uuid-1", "sess-A", "Hello");
1081        let line2 = user_line("uuid-2", "sess-A", "World");
1082        let line3 = user_line("uuid-3", "sess-A", "Done");
1083
1084        let mut file = NamedTempFile::new().expect("tmpfile");
1085        writeln!(file, "{line1}").unwrap();
1086        writeln!(file, "{line2}").unwrap();
1087        writeln!(file, "{line3}").unwrap();
1088
1089        let path = file.path().to_path_buf();
1090
1091        // First call: should insert all 3 rows.
1092        let stats = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1093            .await
1094            .expect("mirror_file first call");
1095        assert_eq!(stats.inserted, 3, "should insert 3 new messages");
1096        assert_eq!(stats.scanned, 3, "should scan 3 lines");
1097        assert!(stats.new_offset > 0, "offset should advance");
1098
1099        let msg_count = count_rows(&rt, "session_messages").await;
1100        assert_eq!(msg_count, 3, "3 messages in DB");
1101
1102        let session_count = count_rows(&rt, "sessions").await;
1103        assert_eq!(session_count, 1, "1 session row");
1104
1105        // Idempotency: second call over the SAME range inserts 0 rows.
1106        let stats2 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1107            .await
1108            .expect("mirror_file second call");
1109        assert_eq!(stats2.inserted, 0, "second pass must insert 0 rows");
1110        assert_eq!(count_rows(&rt, "session_messages").await, 3);
1111
1112        // Offset-aware: calling from the advanced offset finds nothing new.
1113        let stats3 = mirror_file(
1114            &rt,
1115            &path,
1116            stats.new_offset,
1117            LineTailSource::ClaudeCode,
1118            None,
1119        )
1120        .await
1121        .expect("mirror_file from new_offset");
1122        assert_eq!(stats3.inserted, 0, "no new data past advanced offset");
1123        assert_eq!(stats3.new_offset, stats.new_offset);
1124
1125        // Cursor was recorded.
1126        let stored_offset = cursor_offset(&rt, &path.to_string_lossy()).await;
1127        assert!(stored_offset.is_some(), "cursor should be recorded");
1128        assert_eq!(stored_offset.unwrap(), stats.new_offset as i64);
1129    }
1130
1131    #[tokio::test]
1132    async fn mirror_file_respects_low_test_cap_and_advances_over_multiple_passes() {
1133        // Regression for PACKSESSION-AUD-003: `mirror_file` used to allocate
1134        // and read the entire file delta in one shot via `read_from_offset`
1135        // (`Vec::with_capacity(file_len - offset)` + `read_to_end`), which
1136        // could OOM or stall the daemon on a very large accumulated delta.
1137        // With a tiny test-only byte cap, a multi-line file must now be
1138        // consumed across multiple bounded passes — each committing its own
1139        // chunk and cursor advance — never reading the whole file at once.
1140        let (rt, _dir) = setup().await;
1141
1142        let lines: Vec<String> = (0..6)
1143            .map(|i| user_line(&format!("uuid-cap-{i}"), "sess-CAP", &format!("line{i}")))
1144            .collect();
1145
1146        let mut file = NamedTempFile::new().expect("tmpfile");
1147        for line in &lines {
1148            writeln!(file, "{line}").unwrap();
1149        }
1150        let path = file.path().to_path_buf();
1151        let file_len = std::fs::metadata(&path).unwrap().len();
1152
1153        // All 6 fixture lines are byte-identical in length, so capping at
1154        // exactly two lines' worth of bytes forces a 2-line-per-pass split
1155        // without needing a giant fixture.
1156        let cap_bytes = (lines[0].len() + 1) + (lines[1].len() + 1);
1157        let limits = MirrorLimits {
1158            max_bytes_per_pass: cap_bytes,
1159            max_events_per_pass: 1024,
1160            max_line_bytes: MIRROR_MAX_LINE_BYTES,
1161        };
1162
1163        let stats1 =
1164            mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1165                .await
1166                .expect("first bounded pass");
1167        assert_eq!(
1168            stats1.inserted, 2,
1169            "first pass must stop at the byte cap, not read the whole file"
1170        );
1171        assert_eq!(stats1.scanned, 2);
1172        assert!(
1173            stats1.new_offset < file_len,
1174            "new_offset {new} must be less than file_len {file_len} for a bounded pass",
1175            new = stats1.new_offset
1176        );
1177        assert_eq!(
1178            cursor_offset(&rt, &path.to_string_lossy()).await,
1179            Some(stats1.new_offset as i64),
1180            "cursor must be committed after the first bounded pass"
1181        );
1182
1183        let stats2 = mirror_file_with_limits(
1184            &rt,
1185            &path,
1186            stats1.new_offset,
1187            LineTailSource::ClaudeCode,
1188            None,
1189            limits,
1190        )
1191        .await
1192        .expect("second bounded pass");
1193        assert_eq!(stats2.inserted, 2);
1194        assert!(stats2.new_offset > stats1.new_offset);
1195        assert!(stats2.new_offset < file_len);
1196
1197        let stats3 = mirror_file_with_limits(
1198            &rt,
1199            &path,
1200            stats2.new_offset,
1201            LineTailSource::ClaudeCode,
1202            None,
1203            limits,
1204        )
1205        .await
1206        .expect("third bounded pass");
1207        assert_eq!(stats3.inserted, 2);
1208        assert_eq!(stats3.new_offset, file_len, "final pass must reach EOF");
1209
1210        // All 6 rows landed across 3 bounded passes, and the cursor reflects
1211        // the full file — no pass allocated or inserted the entire file at
1212        // once.
1213        assert_eq!(count_rows(&rt, "session_messages").await, 6);
1214        assert_eq!(
1215            cursor_offset(&rt, &path.to_string_lossy()).await,
1216            Some(file_len as i64)
1217        );
1218
1219        // A pass with no remaining bytes is a clean no-op.
1220        let stats4 = mirror_file_with_limits(
1221            &rt,
1222            &path,
1223            stats3.new_offset,
1224            LineTailSource::ClaudeCode,
1225            None,
1226            limits,
1227        )
1228        .await
1229        .expect("fourth pass at EOF");
1230        assert_eq!(stats4.inserted, 0);
1231        assert_eq!(stats4.scanned, 0);
1232    }
1233
1234    #[tokio::test]
1235    async fn test_oversized_line_is_skipped_and_offset_advances() {
1236        // Regression for PACKSESSION-AUD-003 (High): a single complete line
1237        // larger than `max_line_bytes` must never be fully buffered and
1238        // parsed — it must be rejected outright, with the offset advancing
1239        // past it so ingestion does not wedge, and surrounding valid lines
1240        // in the same pass still land.
1241        let (rt, _dir) = setup().await;
1242
1243        let small1 = user_line("uuid-small1", "sess-OV", "ok");
1244        let huge_text = "x".repeat(2000);
1245        let huge = user_line("uuid-huge", "sess-OV", &huge_text);
1246        let small2 = user_line("uuid-small2", "sess-OV", "after");
1247
1248        let mut file = NamedTempFile::new().expect("tmpfile");
1249        writeln!(file, "{small1}").unwrap();
1250        writeln!(file, "{huge}").unwrap();
1251        writeln!(file, "{small2}").unwrap();
1252        let path = file.path().to_path_buf();
1253        let file_len = std::fs::metadata(&path).unwrap().len();
1254
1255        let max_line_bytes: usize = 256;
1256        assert!(
1257            huge.len() + 1 > max_line_bytes,
1258            "fixture huge line must exceed the cap"
1259        );
1260        assert!(
1261            small1.len() + 1 < max_line_bytes && small2.len() + 1 < max_line_bytes,
1262            "fixture small lines must fit under the cap"
1263        );
1264
1265        let limits = MirrorLimits {
1266            max_bytes_per_pass: MIRROR_MAX_BYTES_PER_PASS,
1267            max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
1268            max_line_bytes,
1269        };
1270
1271        let stats =
1272            mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1273                .await
1274                .expect("mirror with a small line cap");
1275
1276        assert_eq!(stats.inserted, 2, "only the two small lines are inserted");
1277        assert_eq!(
1278            stats.scanned, 2,
1279            "the oversized line must not count toward scanned"
1280        );
1281        assert_eq!(
1282            stats.new_offset, file_len,
1283            "offset must advance past the oversized line, not wedge on it"
1284        );
1285        assert_eq!(count_rows(&rt, "session_messages").await, 2);
1286    }
1287
1288    #[tokio::test]
1289    async fn test_line_just_under_cap_then_oversized_next_line_is_bounded() {
1290        // Regression for PACKSESSION-AUD-003 (High): the old bound only
1291        // checked the pass cap before reading another line, so a line that
1292        // starts under the cap but is followed by one that balloons far
1293        // beyond it could still get fully buffered via `read_until` before
1294        // any check ran. The per-line bound must catch this regardless of
1295        // where in the pass it happens.
1296        let (rt, _dir) = setup().await;
1297
1298        let max_line_bytes: usize = 256;
1299        let shell_len = user_line("uuid-a", "sess-BND", "").len() + 1; // + '\n'
1300        let pad = max_line_bytes.saturating_sub(shell_len).saturating_sub(4);
1301        let text_a = "y".repeat(pad);
1302        let line_a = user_line("uuid-a", "sess-BND", &text_a);
1303
1304        let huge_text = "z".repeat(max_line_bytes * 4);
1305        let line_b = user_line("uuid-b", "sess-BND", &huge_text);
1306
1307        let mut file = NamedTempFile::new().expect("tmpfile");
1308        writeln!(file, "{line_a}").unwrap();
1309        writeln!(file, "{line_b}").unwrap();
1310        let path = file.path().to_path_buf();
1311        let file_len = std::fs::metadata(&path).unwrap().len();
1312
1313        assert!(
1314            line_a.len() + 1 < max_line_bytes,
1315            "fixture line A must land just under the cap"
1316        );
1317        assert!(
1318            line_b.len() + 1 > max_line_bytes,
1319            "fixture line B must land over the cap"
1320        );
1321
1322        let limits = MirrorLimits {
1323            max_bytes_per_pass: MIRROR_MAX_BYTES_PER_PASS,
1324            max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
1325            max_line_bytes,
1326        };
1327
1328        let stats =
1329            mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1330                .await
1331                .expect("mirror with a boundary line cap");
1332
1333        assert_eq!(stats.inserted, 1, "only the under-cap line is inserted");
1334        assert_eq!(
1335            stats.scanned, 1,
1336            "the oversized line must not count toward scanned"
1337        );
1338        assert_eq!(
1339            stats.new_offset, file_len,
1340            "offset must advance past both lines, including the skipped oversized one"
1341        );
1342        assert_eq!(count_rows(&rt, "session_messages").await, 1);
1343    }
1344
1345    /// Counts every byte pulled through `Read::read`, so a test can assert a
1346    /// hard ceiling on how much of the underlying source `read_line_bounded`
1347    /// ever touches in one call — independent of how large the backing
1348    /// buffer actually is.
1349    struct CountingReader<R> {
1350        inner: R,
1351        total_read: std::rc::Rc<std::cell::Cell<usize>>,
1352    }
1353
1354    impl<R: std::io::Read> std::io::Read for CountingReader<R> {
1355        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1356            let n = self.inner.read(buf)?;
1357            self.total_read.set(self.total_read.get() + n);
1358            Ok(n)
1359        }
1360    }
1361
1362    #[test]
1363    fn test_read_line_bounded_oversized_unterminated_reads_are_capped_per_call() {
1364        // Regression for PACKSESSION-AUD-003 round 2 (High): a huge final
1365        // line with no trailing `\n` used to be scanned all the way to EOF
1366        // in one `read_line_bounded` call (`fill_buf`/`consume` looped until
1367        // the reader ran dry), even though the discarded bytes past the cap
1368        // were never buffered. The READ itself must be bounded too, not just
1369        // the buffered memory. Use a small, explicit `BufReader` capacity so
1370        // the bound is provable independent of the platform default (8KiB).
1371        let max_line_bytes: usize = 64;
1372        let buf_capacity: usize = 256;
1373        // Far larger than max_line_bytes + a handful of buffer refills, and
1374        // containing NO '\n' anywhere — the pathological unterminated case.
1375        let data = vec![b'x'; 200_000];
1376
1377        let total_read = std::rc::Rc::new(std::cell::Cell::new(0));
1378        let counting = CountingReader {
1379            inner: std::io::Cursor::new(data),
1380            total_read: total_read.clone(),
1381        };
1382        let mut reader = std::io::BufReader::with_capacity(buf_capacity, counting);
1383        let mut buf = Vec::new();
1384
1385        let outcome =
1386            read_line_bounded(&mut reader, &mut buf, max_line_bytes).expect("read must not error");
1387
1388        match outcome {
1389            LineRead::OversizedUnterminated { bytes } => {
1390                assert!(
1391                    bytes > max_line_bytes,
1392                    "must have detected the crossing of the cap, got {bytes}"
1393                );
1394            }
1395            other => panic!("expected OversizedUnterminated, got {other:?}"),
1396        }
1397        assert!(
1398            buf.is_empty(),
1399            "buf must never buffer anything once the line is flagged oversized"
1400        );
1401
1402        // The load-bearing assertion: total bytes ever pulled from the
1403        // underlying 200,000-byte source must be bounded to roughly
1404        // max_line_bytes plus a small, constant number of buffer refills —
1405        // never anywhere close to scanning the whole remaining file.
1406        let read_bytes = total_read.get();
1407        assert!(
1408            read_bytes <= max_line_bytes + buf_capacity * 4,
1409            "read_line_bounded pulled {read_bytes} bytes from the source for an \
1410             unterminated oversized line — expected at most {} (bounded to the \
1411             cap plus a few buffer refills), not an unbounded scan toward EOF",
1412            max_line_bytes + buf_capacity * 4
1413        );
1414    }
1415
1416    #[tokio::test]
1417    async fn test_oversized_unterminated_line_leaves_cursor_at_line_start_and_is_bounded_on_retry()
1418    {
1419        // Regression for PACKSESSION-AUD-003 round 2 (High): a single huge
1420        // line with NO trailing newline (a still-growing or corrupt final
1421        // line) must not advance the cursor, and repeated calls from the
1422        // same persisted offset must each be bounded, not replay an
1423        // unbounded scan of the whole file every poll.
1424        let (rt, _dir) = setup().await;
1425
1426        let max_line_bytes: usize = 256;
1427        // One line, far larger than the cap, with no terminating '\n' at all.
1428        let huge_unterminated = "z".repeat(max_line_bytes * 20);
1429
1430        let mut file = NamedTempFile::new().expect("tmpfile");
1431        file.write_all(huge_unterminated.as_bytes())
1432            .expect("write unterminated line");
1433        let path = file.path().to_path_buf();
1434
1435        let limits = MirrorLimits {
1436            max_bytes_per_pass: MIRROR_MAX_BYTES_PER_PASS,
1437            max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
1438            max_line_bytes,
1439        };
1440
1441        // First pass: the oversized-unterminated line must not advance the
1442        // cursor at all (same policy as an ordinary `Partial`).
1443        let stats1 =
1444            mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1445                .await
1446                .expect("first pass over an unterminated oversized line");
1447        assert_eq!(
1448            stats1.new_offset, 0,
1449            "cursor must stay at the line start — nothing was durably consumed"
1450        );
1451        assert_eq!(stats1.scanned, 0);
1452        assert_eq!(stats1.inserted, 0);
1453        assert_eq!(
1454            count_rows(&rt, "session_messages").await,
1455            0,
1456            "no partial/garbage row may be written for an unterminated oversized line"
1457        );
1458
1459        // Second pass from the persisted (unchanged) offset behaves
1460        // identically — a durable, bounded retry, never a wedge that grows
1461        // unboundedly worse, and no replay of previously-seen bytes as new
1462        // events (there were none).
1463        let stats2 = mirror_file_with_limits(
1464            &rt,
1465            &path,
1466            stats1.new_offset,
1467            LineTailSource::ClaudeCode,
1468            None,
1469            limits,
1470        )
1471        .await
1472        .expect("second pass (simulated daemon restart) over the same unterminated line");
1473        assert_eq!(stats2.new_offset, 0);
1474        assert_eq!(stats2.scanned, 0);
1475        assert_eq!(stats2.inserted, 0);
1476        assert_eq!(count_rows(&rt, "session_messages").await, 0);
1477
1478        // Now the line completes (append a terminating '\n' and a bit more,
1479        // simulating the file finishing its write): it must be recognized
1480        // as the ordinary complete-oversized-line skip, advance past it, and
1481        // ingest anything that follows normally.
1482        let small_after = user_line("uuid-after-huge", "sess-UNTERM", "after");
1483        {
1484            let mut f = std::fs::OpenOptions::new()
1485                .append(true)
1486                .open(&path)
1487                .expect("reopen for append");
1488            writeln!(f).unwrap(); // terminate the huge line
1489            writeln!(f, "{small_after}").unwrap();
1490        }
1491        let file_len = std::fs::metadata(&path).unwrap().len();
1492
1493        let stats3 = mirror_file_with_limits(
1494            &rt,
1495            &path,
1496            stats2.new_offset,
1497            LineTailSource::ClaudeCode,
1498            None,
1499            limits,
1500        )
1501        .await
1502        .expect("third pass once the huge line terminates");
1503        assert_eq!(
1504            stats3.new_offset, file_len,
1505            "once terminated, the skip-and-advance path must clear past the whole \
1506             oversized line plus the following valid line"
1507        );
1508        assert_eq!(stats3.scanned, 1, "only the small trailing line is scanned");
1509        assert_eq!(stats3.inserted, 1);
1510        assert_eq!(count_rows(&rt, "session_messages").await, 1);
1511    }
1512
1513    #[tokio::test]
1514    async fn test_still_growing_partial_line_under_cap_is_unaffected() {
1515        // Guard: an ordinary still-growing file whose latest line is under
1516        // `max_line_bytes` and has no newline YET must still behave as a
1517        // plain `Partial` — cursor does not advance past it, and once the
1518        // line completes on a later pass it is picked up normally. This
1519        // must not regress from the new oversized-unterminated handling.
1520        let (rt, _dir) = setup().await;
1521
1522        let small1 = user_line("uuid-g1", "sess-GROW", "first");
1523        let mut file = NamedTempFile::new().expect("tmpfile");
1524        writeln!(file, "{small1}").unwrap();
1525        // Partial trailing line: valid JSON-shaped prefix, no newline yet.
1526        let partial_prefix = user_line("uuid-g2", "sess-GROW", "second");
1527        file.write_all(partial_prefix.as_bytes())
1528            .expect("write partial line, no trailing newline");
1529        let path = file.path().to_path_buf();
1530
1531        let limits = MirrorLimits::production();
1532
1533        let stats1 =
1534            mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1535                .await
1536                .expect("first pass: complete line + partial trailing line");
1537        assert_eq!(stats1.scanned, 1, "only the complete first line is scanned");
1538        assert_eq!(stats1.inserted, 1);
1539        assert_eq!(
1540            stats1.new_offset,
1541            (small1.len() + 1) as u64,
1542            "cursor must stop right after the first complete line, not consume the partial tail"
1543        );
1544
1545        // The file "grows": the trailing line now gets its newline.
1546        writeln!(file).unwrap();
1547        let file_len = std::fs::metadata(&path).unwrap().len();
1548
1549        let stats2 = mirror_file_with_limits(
1550            &rt,
1551            &path,
1552            stats1.new_offset,
1553            LineTailSource::ClaudeCode,
1554            None,
1555            limits,
1556        )
1557        .await
1558        .expect("second pass: the previously-partial line now completes");
1559        assert_eq!(stats2.new_offset, file_len);
1560        assert_eq!(stats2.scanned, 1);
1561        assert_eq!(stats2.inserted, 1);
1562        assert_eq!(count_rows(&rt, "session_messages").await, 2);
1563    }
1564
1565    #[tokio::test]
1566    async fn test_large_run_of_blank_lines_is_bounded_and_persists_cursor() {
1567        // Regression for PACKSESSION-AUD-003 (Medium): a run of blank lines
1568        // used to bypass the pass cap (only nonblank `scanned` lines tripped
1569        // it) and, when a chunk scanned zero events, the cursor was never
1570        // persisted even though bytes had durably advanced. Both must be
1571        // fixed: the cap must trip on blank lines too, and the cursor must
1572        // be written whenever the pass consumed any bytes.
1573        let (rt, _dir) = setup().await;
1574
1575        let mut file = NamedTempFile::new().expect("tmpfile");
1576        for _ in 0..500 {
1577            writeln!(file).unwrap(); // blank line: just "\n"
1578        }
1579        let path = file.path().to_path_buf();
1580        let file_len = std::fs::metadata(&path).unwrap().len();
1581        assert_eq!(file_len, 500, "500 one-byte blank lines");
1582
1583        // A tiny per-pass byte cap forces the blank-line run across multiple
1584        // passes instead of reading straight to EOF in one call.
1585        let limits = MirrorLimits {
1586            max_bytes_per_pass: 50,
1587            max_events_per_pass: MIRROR_MAX_EVENTS_PER_PASS,
1588            max_line_bytes: MIRROR_MAX_LINE_BYTES,
1589        };
1590
1591        let stats1 =
1592            mirror_file_with_limits(&rt, &path, 0, LineTailSource::ClaudeCode, None, limits)
1593                .await
1594                .expect("first blank-line pass");
1595
1596        assert_eq!(stats1.inserted, 0);
1597        assert_eq!(stats1.scanned, 0, "blank lines never count toward scanned");
1598        assert!(
1599            stats1.new_offset > 0,
1600            "the pass cap must trip after at least one blank line, not read unbounded"
1601        );
1602        assert!(
1603            stats1.new_offset < file_len,
1604            "a bounded pass over an all-blank file must not reach EOF in one call"
1605        );
1606
1607        // The cursor must be durably persisted even though `scanned == 0`.
1608        let stored_offset = cursor_offset(&rt, &path.to_string_lossy()).await;
1609        assert_eq!(
1610            stored_offset,
1611            Some(stats1.new_offset as i64),
1612            "cursor must be persisted even when the pass scanned zero events"
1613        );
1614
1615        // Repeated calls continue from the persisted offset (not from 0) and
1616        // eventually reach EOF, never re-reading already-consumed blanks.
1617        let mut offset = stats1.new_offset;
1618        loop {
1619            let stats = mirror_file_with_limits(
1620                &rt,
1621                &path,
1622                offset,
1623                LineTailSource::ClaudeCode,
1624                None,
1625                limits,
1626            )
1627            .await
1628            .expect("subsequent blank-line pass");
1629            assert_eq!(stats.inserted, 0);
1630            if stats.new_offset == offset {
1631                break; // EOF reached, no further progress
1632            }
1633            offset = stats.new_offset;
1634        }
1635        assert_eq!(
1636            offset, file_len,
1637            "all blank lines eventually consumed to EOF"
1638        );
1639    }
1640
1641    #[tokio::test]
1642    async fn test_partial_trailing_line_not_consumed() {
1643        let (rt, _dir) = setup().await;
1644
1645        let line1 = user_line("uuid-p1", "sess-B", "Complete");
1646        // Write one complete line + a partial line without trailing '\n'.
1647        let partial = r#"{"uuid":"uuid-p2","sessionId":"sess-B","type":"user""#;
1648
1649        let mut file = NamedTempFile::new().expect("tmpfile");
1650        writeln!(file, "{line1}").unwrap(); // complete line (has \n)
1651        write!(file, "{partial}").unwrap(); // partial — NO trailing \n
1652
1653        let path = file.path().to_path_buf();
1654        let full_len = std::fs::metadata(&path).unwrap().len();
1655
1656        let stats = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1657            .await
1658            .expect("mirror_file partial");
1659
1660        // Only the complete line should be consumed.
1661        assert_eq!(stats.inserted, 1, "only 1 complete line inserted");
1662        assert!(
1663            stats.new_offset < full_len,
1664            "new_offset {new} must be less than file_len {full_len}",
1665            new = stats.new_offset
1666        );
1667
1668        // The partial bytes remain; calling again from new_offset finds no complete lines.
1669        let stats2 = mirror_file(
1670            &rt,
1671            &path,
1672            stats.new_offset,
1673            LineTailSource::ClaudeCode,
1674            None,
1675        )
1676        .await
1677        .expect("second call");
1678        assert_eq!(
1679            stats2.inserted, 0,
1680            "partial line must not be consumed on re-poll"
1681        );
1682        assert_eq!(
1683            stats2.new_offset, stats.new_offset,
1684            "offset must not advance on partial-only content"
1685        );
1686    }
1687
1688    #[tokio::test]
1689    async fn test_duplicate_uuid_across_two_calls() {
1690        let (rt, _dir) = setup().await;
1691
1692        let line = user_line("uuid-dup", "sess-C", "First");
1693
1694        let mut file = NamedTempFile::new().expect("tmpfile");
1695        writeln!(file, "{line}").unwrap();
1696
1697        let path = file.path().to_path_buf();
1698
1699        // First call inserts.
1700        let s1 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1701            .await
1702            .unwrap();
1703        assert_eq!(s1.inserted, 1);
1704
1705        // Append same uuid again.
1706        writeln!(file, "{line}").unwrap();
1707
1708        // Second call from offset 0 should see both lines but insert 0 new rows.
1709        let s2 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1710            .await
1711            .unwrap();
1712        assert_eq!(s2.inserted, 0, "duplicate uuid must not be re-inserted");
1713        assert_eq!(count_rows(&rt, "session_messages").await, 1);
1714
1715        // Incremental: call from first call's new_offset; the second line is the dup.
1716        let s3 = mirror_file(&rt, &path, s1.new_offset, LineTailSource::ClaudeCode, None)
1717            .await
1718            .unwrap();
1719        assert_eq!(s3.inserted, 0, "incremental dup must also insert 0");
1720    }
1721
1722    #[tokio::test]
1723    async fn test_replay_does_not_mutate_session_metadata() {
1724        // Regression for the replay-idempotency finding: a timestamp-missing
1725        // event's `created_at` falls back to `now_us`, which differs between
1726        // calls. A pure replay (0 new messages) must NOT advance `last_seen_at`
1727        // or otherwise touch the session row.
1728        let (rt, _dir) = setup().await;
1729
1730        let line = user_line_no_ts("uuid-nts", "sess-NTS", "no timestamp here");
1731        let mut file = NamedTempFile::new().expect("tmpfile");
1732        writeln!(file, "{line}").unwrap();
1733        let path = file.path().to_path_buf();
1734
1735        let s1 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1736            .await
1737            .unwrap();
1738        assert_eq!(s1.inserted, 1);
1739        let seen_after_first = last_seen_at(&rt, "sess-NTS")
1740            .await
1741            .expect("session row exists");
1742
1743        // Replay from offset 0: re-scans the same line, inserts 0, and must
1744        // leave last_seen_at byte-identical even though now_us has advanced.
1745        let s2 = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1746            .await
1747            .unwrap();
1748        assert_eq!(s2.inserted, 0, "replay must insert 0 rows");
1749        let seen_after_replay = last_seen_at(&rt, "sess-NTS").await.unwrap();
1750        assert_eq!(
1751            seen_after_first, seen_after_replay,
1752            "replay must not advance last_seen_at for a timestamp-missing event"
1753        );
1754    }
1755
1756    #[tokio::test]
1757    async fn test_empty_file_is_a_no_op() {
1758        let (rt, _dir) = setup().await;
1759
1760        let file = NamedTempFile::new().expect("tmpfile");
1761        let path = file.path().to_path_buf();
1762
1763        let stats = mirror_file(&rt, &path, 0, LineTailSource::ClaudeCode, None)
1764            .await
1765            .unwrap();
1766        assert_eq!(stats.inserted, 0);
1767        assert_eq!(stats.scanned, 0);
1768        assert_eq!(stats.new_offset, 0);
1769    }
1770
1771    #[tokio::test]
1772    async fn test_missing_file_returns_error() {
1773        let (rt, _dir) = setup().await;
1774        let bad_path = std::path::PathBuf::from("/nonexistent/path/session.jsonl");
1775        let result = mirror_file(&rt, &bad_path, 0, LineTailSource::ClaudeCode, None).await;
1776        assert!(
1777            matches!(result, Err(RuntimeError::Internal(_))),
1778            "missing file should return Internal error"
1779        );
1780    }
1781
1782    // ── Codex source integration tests ────────────────────────────────────────
1783
1784    /// Build a minimal Codex response_item/message line. Block type mirrors the
1785    /// real shape: `input_text` for user messages, `output_text` for assistant
1786    /// messages (the generic `text` type does not occur in real Codex transcripts).
1787    fn codex_message_line(role: &str, text: &str) -> String {
1788        let block_type = if role == "assistant" {
1789            "output_text"
1790        } else {
1791            "input_text"
1792        };
1793        format!(
1794            r#"{{"type":"response_item","timestamp":"2026-06-30T08:00:00Z","payload":{{"type":"message","role":"{role}","content":[{{"type":"{block_type}","text":"{text}"}}]}}}}"#
1795        )
1796    }
1797
1798    /// Build a minimal Codex session_meta line.
1799    fn codex_meta_line(session_id: &str, cwd: &str, branch: &str) -> String {
1800        format!(
1801            r#"{{"type":"session_meta","timestamp":"2026-06-30T08:00:00Z","payload":{{"id":"{session_id}","cwd":"{cwd}","git":{{"branch":"{branch}","commit_hash":"abc","repository_url":"https://github.com/example/repo"}}}}}}"#
1802        )
1803    }
1804
1805    /// Build a Codex event_msg line (should be skipped).
1806    fn codex_event_msg_line() -> String {
1807        r#"{"type":"event_msg","timestamp":"2026-06-30T08:00:00Z","payload":{"type":"user_message","content":"should be skipped"}}"#.to_string()
1808    }
1809
1810    #[tokio::test]
1811    async fn test_codex_mirror_inserts_with_source_codex() {
1812        let (rt, _dir) = setup().await;
1813
1814        let session_id = "cdx-sess-0001-0001-0001-000000000001";
1815        let meta = codex_meta_line(session_id, "/home/lion/proj", "feat-x");
1816        let user_msg = codex_message_line("user", "Hello from Codex");
1817        let asst_msg = codex_message_line("assistant", "Hello back from Codex");
1818        let skip_msg = codex_event_msg_line();
1819
1820        let mut file = NamedTempFile::new().expect("tmpfile");
1821        writeln!(file, "{meta}").unwrap();
1822        writeln!(file, "{user_msg}").unwrap();
1823        writeln!(file, "{asst_msg}").unwrap();
1824        writeln!(file, "{skip_msg}").unwrap();
1825
1826        let path = file.path().to_path_buf();
1827
1828        // Mirror the file as Codex source.
1829        let stats = mirror_file(&rt, &path, 0, LineTailSource::Codex, Some(session_id))
1830            .await
1831            .expect("codex mirror_file");
1832
1833        // session_meta + 2 response_item/message rows = 3 parseable, event_msg skipped.
1834        assert_eq!(stats.inserted, 3, "meta + 2 messages inserted");
1835        assert_eq!(
1836            stats.scanned, 4,
1837            "4 lines total (including skipped event_msg)"
1838        );
1839        assert!(stats.new_offset > 0);
1840
1841        // Session row exists with source='codex'.
1842        let sql = rt.sql();
1843        let mut r = sql.reader().await.expect("reader");
1844        let session_row = r
1845            .query_row(SqlStatement {
1846                sql: "SELECT source FROM sessions WHERE id=?1".into(),
1847                params: vec![SqlValue::Text(session_id.to_string())],
1848                label: None,
1849            })
1850            .await
1851            .expect("query ok")
1852            .expect("session row must exist");
1853        match session_row.columns.first().map(|c| &c.value) {
1854            Some(SqlValue::Text(s)) => assert_eq!(s, "codex", "source must be 'codex'"),
1855            other => panic!("unexpected source value: {other:?}"),
1856        }
1857
1858        // All 3 message rows are stored.
1859        assert_eq!(count_rows(&rt, "session_messages").await, 3);
1860
1861        // The two response_item/message rows carry their real input_text/
1862        // output_text content through to session_messages.text — not just a
1863        // row count, but the actual extracted string for each role.
1864        let mut r2 = sql.reader().await.expect("reader");
1865        let rows = r2
1866            .query_all(SqlStatement {
1867                sql: "SELECT role, text FROM session_messages \
1868                      WHERE session_id=?1 AND role IS NOT NULL ORDER BY seq"
1869                    .into(),
1870                params: vec![SqlValue::Text(session_id.to_string())],
1871                label: None,
1872            })
1873            .await
1874            .expect("query ok");
1875        let texts: Vec<(String, String)> = rows
1876            .iter()
1877            .map(|row| {
1878                let role = match row.get("role") {
1879                    Some(SqlValue::Text(s)) => s.clone(),
1880                    other => panic!("unexpected role value: {other:?}"),
1881                };
1882                let text = match row.get("text") {
1883                    Some(SqlValue::Text(s)) => s.clone(),
1884                    other => panic!("unexpected text value: {other:?}"),
1885                };
1886                (role, text)
1887            })
1888            .collect();
1889        assert_eq!(
1890            texts,
1891            vec![
1892                ("user".to_string(), "Hello from Codex".to_string()),
1893                ("assistant".to_string(), "Hello back from Codex".to_string()),
1894            ],
1895            "input_text/output_text blocks must round-trip to session_messages.text by role"
1896        );
1897    }
1898
1899    #[tokio::test]
1900    async fn test_codex_event_id_is_stable_and_idempotent() {
1901        // Verifies that: (a) synthetic uuid format is "{session_id}:{offset}",
1902        // and (b) a second mirror_file pass over the same bytes inserts 0 rows.
1903        let (rt, _dir) = setup().await;
1904
1905        let session_id = "cdx-sess-idem-0001-0001-000000000002";
1906        let user_msg = codex_message_line("user", "Idempotency test");
1907
1908        let mut file = NamedTempFile::new().expect("tmpfile");
1909        writeln!(file, "{user_msg}").unwrap();
1910
1911        let path = file.path().to_path_buf();
1912
1913        // First pass.
1914        let s1 = mirror_file(&rt, &path, 0, LineTailSource::Codex, Some(session_id))
1915            .await
1916            .unwrap();
1917        assert_eq!(s1.inserted, 1);
1918
1919        // Verify the stored id matches the expected synthetic format.
1920        let sql = rt.sql();
1921        let mut r = sql.reader().await.expect("reader");
1922        let msg_row = r
1923            .query_row(SqlStatement {
1924                sql: "SELECT id FROM session_messages WHERE session_id=?1".into(),
1925                params: vec![SqlValue::Text(session_id.to_string())],
1926                label: None,
1927            })
1928            .await
1929            .expect("query ok")
1930            .expect("message row must exist");
1931        let stored_id = match msg_row.columns.first().map(|c| &c.value) {
1932            Some(SqlValue::Text(s)) => s.clone(),
1933            other => panic!("unexpected id type: {other:?}"),
1934        };
1935        // The line starts at byte offset 0.
1936        let expected_id = format!("{session_id}:0");
1937        assert_eq!(
1938            stored_id, expected_id,
1939            "synthetic uuid must be {{session_id}}:{{offset}}"
1940        );
1941
1942        // Second pass from offset 0: same lines, 0 new rows (idempotent).
1943        let s2 = mirror_file(&rt, &path, 0, LineTailSource::Codex, Some(session_id))
1944            .await
1945            .unwrap();
1946        assert_eq!(s2.inserted, 0, "second pass must be idempotent");
1947        assert_eq!(count_rows(&rt, "session_messages").await, 1);
1948
1949        // Incremental pass from advanced offset: no new data.
1950        let s3 = mirror_file(
1951            &rt,
1952            &path,
1953            s1.new_offset,
1954            LineTailSource::Codex,
1955            Some(session_id),
1956        )
1957        .await
1958        .unwrap();
1959        assert_eq!(s3.inserted, 0, "incremental pass finds nothing new");
1960    }
1961
1962    #[tokio::test]
1963    async fn test_codex_and_cc_are_independent_sessions() {
1964        // Both sources can coexist in the same DB; source column distinguishes them.
1965        let (rt, _dir) = setup().await;
1966
1967        let cc_line = user_line("cc-uuid-1", "cc-sess-1", "from claude code");
1968        let mut cc_file = NamedTempFile::new().expect("cc tmpfile");
1969        writeln!(cc_file, "{cc_line}").unwrap();
1970
1971        let cdx_session_id = "cdx-sess-coex-0001-0001-000000000003";
1972        let cdx_msg = codex_message_line("user", "from codex");
1973        let mut cdx_file = NamedTempFile::new().expect("cdx tmpfile");
1974        writeln!(cdx_file, "{cdx_msg}").unwrap();
1975
1976        mirror_file(&rt, cc_file.path(), 0, LineTailSource::ClaudeCode, None)
1977            .await
1978            .unwrap();
1979
1980        mirror_file(
1981            &rt,
1982            cdx_file.path(),
1983            0,
1984            LineTailSource::Codex,
1985            Some(cdx_session_id),
1986        )
1987        .await
1988        .unwrap();
1989
1990        assert_eq!(count_rows(&rt, "sessions").await, 2);
1991        assert_eq!(count_rows(&rt, "session_messages").await, 2);
1992
1993        // Verify sources are distinct.
1994        let sql = rt.sql();
1995        let mut r = sql.reader().await.expect("reader");
1996        let rows = r
1997            .query_all(SqlStatement {
1998                sql: "SELECT source FROM sessions ORDER BY source".into(),
1999                params: vec![],
2000                label: None,
2001            })
2002            .await
2003            .expect("query ok");
2004        let sources: Vec<String> = rows
2005            .iter()
2006            .filter_map(|row| match row.get("source") {
2007                Some(SqlValue::Text(s)) => Some(s.clone()),
2008                _ => None,
2009            })
2010            .collect();
2011        assert_eq!(sources, vec!["claude_code", "codex"]);
2012    }
2013
2014    // ── ChatGPT export whole-file ingest tests ────────────────────────────────
2015    //
2016    // All fixtures below are hand-authored synthetic JSON, not real export
2017    // content. Node ids are set equal to their own `message.id` so that
2018    // `parent_uuid` (which threads through the mapping node id, per
2019    // `parse::build_chatgpt_event`) resolves to the expected message uuid.
2020
2021    use serde_json::json;
2022
2023    fn write_export_file(content: &str) -> (NamedTempFile, std::path::PathBuf) {
2024        let mut file = NamedTempFile::new().expect("tmpfile");
2025        write!(file, "{content}").unwrap();
2026        let path = file.path().to_path_buf();
2027        (file, path)
2028    }
2029
2030    fn chatgpt_happy_export_json() -> String {
2031        let conv = json!({
2032            "id": "conv-happy",
2033            "title": "Synthetic Happy",
2034            "create_time": 1_751_462_400.0,
2035            "current_node": "msg-happy-assistant",
2036            "mapping": {
2037                "root-happy": {
2038                    "id": "root-happy",
2039                    "message": null,
2040                    "parent": null,
2041                    "children": ["msg-happy-user"]
2042                },
2043                "msg-happy-user": {
2044                    "id": "msg-happy-user",
2045                    "parent": "root-happy",
2046                    "children": ["msg-happy-assistant"],
2047                    "message": {
2048                        "id": "msg-happy-user",
2049                        "author": {"role": "user"},
2050                        "create_time": 1_751_462_400.0,
2051                        "content": {"content_type": "text", "parts": ["Hello synthetic"]}
2052                    }
2053                },
2054                "msg-happy-assistant": {
2055                    "id": "msg-happy-assistant",
2056                    "parent": "msg-happy-user",
2057                    "children": [],
2058                    "message": {
2059                        "id": "msg-happy-assistant",
2060                        "author": {"role": "assistant"},
2061                        "create_time": 1_751_462_401.0,
2062                        "content": {"content_type": "text", "parts": ["Hi synthetic"]}
2063                    }
2064                }
2065            }
2066        });
2067        serde_json::to_string(&json!([conv])).unwrap()
2068    }
2069
2070    #[tokio::test]
2071    async fn test_chatgpt_happy_conversations_json() {
2072        let (rt, _dir) = setup().await;
2073        let (_file, path) = write_export_file(&chatgpt_happy_export_json());
2074        let file_len = std::fs::metadata(&path).unwrap().len();
2075
2076        let stats = mirror_chatgpt_export_file(&rt, &path, 0)
2077            .await
2078            .expect("happy path ingest");
2079        assert_eq!(stats.inserted, 2, "2 message-bearing nodes");
2080        assert_eq!(stats.scanned, 2, "2 events parsed");
2081        assert_eq!(stats.new_offset, file_len, "whole-file cursor-at-length");
2082
2083        let sql = rt.sql();
2084        let mut r = sql.reader().await.expect("reader");
2085        let row = r
2086            .query_row(SqlStatement {
2087                sql: "SELECT source, slug, cwd, git_branch FROM sessions WHERE id='conv-happy'"
2088                    .into(),
2089                params: vec![],
2090                label: None,
2091            })
2092            .await
2093            .expect("query ok")
2094            .expect("session row must exist");
2095        match row.get("source") {
2096            Some(SqlValue::Text(s)) => assert_eq!(s, "chatgpt_export"),
2097            other => panic!("unexpected source: {other:?}"),
2098        }
2099        match row.get("slug") {
2100            Some(SqlValue::Text(s)) => assert_eq!(s, "Synthetic Happy"),
2101            other => panic!("unexpected slug: {other:?}"),
2102        }
2103        assert!(
2104            matches!(row.get("cwd"), Some(SqlValue::Null) | None),
2105            "chatgpt export never carries a cwd"
2106        );
2107        assert!(
2108            matches!(row.get("git_branch"), Some(SqlValue::Null) | None),
2109            "chatgpt export never carries a git branch"
2110        );
2111
2112        let mut r2 = sql.reader().await.expect("reader");
2113        let rows = r2
2114            .query_all(SqlStatement {
2115                sql: "SELECT seq, role FROM session_messages \
2116                      WHERE session_id='conv-happy' ORDER BY seq"
2117                    .into(),
2118                params: vec![],
2119                label: None,
2120            })
2121            .await
2122            .expect("query ok");
2123        let roles: Vec<(i64, String)> = rows
2124            .iter()
2125            .map(|row| {
2126                let seq = match row.get("seq") {
2127                    Some(SqlValue::Integer(n)) => *n,
2128                    other => panic!("unexpected seq: {other:?}"),
2129                };
2130                let role = match row.get("role") {
2131                    Some(SqlValue::Text(s)) => s.clone(),
2132                    other => panic!("unexpected role: {other:?}"),
2133                };
2134                (seq, role)
2135            })
2136            .collect();
2137        assert_eq!(
2138            roles,
2139            vec![(0, "user".to_string()), (1, "assistant".to_string())]
2140        );
2141    }
2142
2143    fn chatgpt_idempotency_export_json() -> String {
2144        let conv = json!({
2145            "id": "conv-idem",
2146            "title": "Synthetic Idempotency",
2147            "current_node": "msg-idem-assistant",
2148            "mapping": {
2149                "root-idem": {
2150                    "id": "root-idem",
2151                    "message": null,
2152                    "parent": null,
2153                    "children": ["msg-idem-user"]
2154                },
2155                "msg-idem-user": {
2156                    "id": "msg-idem-user",
2157                    "parent": "root-idem",
2158                    "children": ["msg-idem-assistant"],
2159                    "message": {
2160                        "id": "msg-idem-user",
2161                        "author": {"role": "user"},
2162                        "content": {"content_type": "text", "parts": ["Same question again"]}
2163                    }
2164                },
2165                "msg-idem-assistant": {
2166                    "id": "msg-idem-assistant",
2167                    "parent": "msg-idem-user",
2168                    "children": [],
2169                    "message": {
2170                        "id": "msg-idem-assistant",
2171                        "author": {"role": "assistant"},
2172                        "content": {"content_type": "text", "parts": ["Same answer again"]}
2173                    }
2174                }
2175            }
2176        });
2177        serde_json::to_string(&json!([conv])).unwrap()
2178    }
2179
2180    #[tokio::test]
2181    async fn test_chatgpt_reingest_idempotency_conversations_json() {
2182        let (rt, _dir) = setup().await;
2183        let (_file, path) = write_export_file(&chatgpt_idempotency_export_json());
2184
2185        let s1 = mirror_chatgpt_export_file(&rt, &path, 0)
2186            .await
2187            .expect("first ingest");
2188        assert_eq!(s1.inserted, 2);
2189
2190        let seen_after_first = last_seen_at(&rt, "conv-idem")
2191            .await
2192            .expect("session row exists");
2193
2194        // Re-ingest from offset 0 (the service always re-reads the whole file
2195        // for this source): same event uuids, INSERT OR IGNORE must dedup.
2196        let s2 = mirror_chatgpt_export_file(&rt, &path, 0)
2197            .await
2198            .expect("second ingest");
2199        assert_eq!(s2.inserted, 0, "re-ingest must insert 0 new rows");
2200
2201        let sql = rt.sql();
2202        let mut r = sql.reader().await.expect("reader");
2203        let count = r
2204            .query_row(SqlStatement {
2205                sql: "SELECT COUNT(*) FROM session_messages WHERE session_id='conv-idem'".into(),
2206                params: vec![],
2207                label: None,
2208            })
2209            .await
2210            .expect("query ok")
2211            .expect("count row");
2212        match count.columns.first().map(|c| &c.value) {
2213            Some(SqlValue::Integer(n)) => assert_eq!(*n, 2, "message count stays at 2"),
2214            other => panic!("unexpected count: {other:?}"),
2215        }
2216
2217        let seen_after_replay = last_seen_at(&rt, "conv-idem")
2218            .await
2219            .expect("session row still exists");
2220        assert_eq!(
2221            seen_after_first, seen_after_replay,
2222            "pure replay must not advance last_seen_at"
2223        );
2224    }
2225
2226    fn chatgpt_branch_sidechain_export_json() -> String {
2227        let conv = json!({
2228            "id": "conv-branch",
2229            "title": "Synthetic Branch",
2230            "current_node": "msg-branch-main",
2231            "mapping": {
2232                "root-branch": {
2233                    "id": "root-branch",
2234                    "message": null,
2235                    "parent": null,
2236                    "children": ["msg-branch-user"]
2237                },
2238                "msg-branch-user": {
2239                    "id": "msg-branch-user",
2240                    "parent": "root-branch",
2241                    "children": ["msg-branch-main", "msg-branch-alt"],
2242                    "message": {
2243                        "id": "msg-branch-user",
2244                        "author": {"role": "user"},
2245                        "content": {"content_type": "text", "parts": ["Branch question"]}
2246                    }
2247                },
2248                "msg-branch-main": {
2249                    "id": "msg-branch-main",
2250                    "parent": "msg-branch-user",
2251                    "children": [],
2252                    "message": {
2253                        "id": "msg-branch-main",
2254                        "author": {"role": "assistant"},
2255                        "content": {"content_type": "text", "parts": ["Main answer"]}
2256                    }
2257                },
2258                "msg-branch-alt": {
2259                    "id": "msg-branch-alt",
2260                    "parent": "msg-branch-user",
2261                    "children": [],
2262                    "message": {
2263                        "id": "msg-branch-alt",
2264                        "author": {"role": "assistant"},
2265                        "content": {"content_type": "text", "parts": ["Alternate answer"]}
2266                    }
2267                }
2268            }
2269        });
2270        serde_json::to_string(&json!([conv])).unwrap()
2271    }
2272
2273    #[tokio::test]
2274    async fn test_chatgpt_branch_sidechain_conversations_json() {
2275        let (rt, _dir) = setup().await;
2276        let (_file, path) = write_export_file(&chatgpt_branch_sidechain_export_json());
2277
2278        let stats = mirror_chatgpt_export_file(&rt, &path, 0)
2279            .await
2280            .expect("branch ingest");
2281        assert_eq!(stats.inserted, 3, "user + main + alt all stored");
2282
2283        let sql = rt.sql();
2284        let mut r = sql.reader().await.expect("reader");
2285        let rows = r
2286            .query_all(SqlStatement {
2287                sql: "SELECT id, is_sidechain, text FROM session_messages \
2288                      WHERE session_id='conv-branch' ORDER BY id"
2289                    .into(),
2290                params: vec![],
2291                label: None,
2292            })
2293            .await
2294            .expect("query ok");
2295        assert_eq!(rows.len(), 3);
2296
2297        for row in &rows {
2298            let id = match row.get("id") {
2299                Some(SqlValue::Text(s)) => s.clone(),
2300                other => panic!("unexpected id: {other:?}"),
2301            };
2302            let is_sidechain = match row.get("is_sidechain") {
2303                Some(SqlValue::Integer(n)) => *n,
2304                other => panic!("unexpected is_sidechain: {other:?}"),
2305            };
2306            let text = match row.get("text") {
2307                Some(SqlValue::Text(s)) => s.clone(),
2308                other => panic!("unexpected text: {other:?}"),
2309            };
2310            match id.as_str() {
2311                "msg-branch-user" | "msg-branch-main" => {
2312                    assert_eq!(is_sidechain, 0, "{id} is on the current-node path")
2313                }
2314                "msg-branch-alt" => {
2315                    assert_eq!(is_sidechain, 1, "alt branch is off the current-node path");
2316                    assert_eq!(
2317                        text, "Alternate answer",
2318                        "sidechain content must be preserved, not dropped"
2319                    );
2320                }
2321                other => panic!("unexpected message id: {other}"),
2322            }
2323        }
2324    }
2325
2326    #[tokio::test]
2327    async fn test_chatgpt_malformed_conversations_json_cursor_does_not_advance() {
2328        let (rt, _dir) = setup().await;
2329
2330        // Seed the path with a valid (if empty) export and record its cursor.
2331        let (mut file, path) = write_export_file("[]");
2332        let seeded_stats = mirror_chatgpt_export_file(&rt, &path, 0)
2333            .await
2334            .expect("seeding with an empty array is a valid parse");
2335        assert_eq!(seeded_stats.inserted, 0);
2336        let seeded_offset = seeded_stats.new_offset;
2337
2338        let seeded_sessions = count_rows(&rt, "sessions").await;
2339        let seeded_messages = count_rows(&rt, "session_messages").await;
2340
2341        // Overwrite with a longer, malformed (valid-JSON-but-not-an-array) body.
2342        let malformed = r#"{"oops": "not a chatgpt export array"}"#;
2343        file.as_file_mut().set_len(0).expect("truncate");
2344        std::io::Seek::seek(file.as_file_mut(), std::io::SeekFrom::Start(0)).unwrap();
2345        write!(file, "{malformed}").unwrap();
2346
2347        let result = mirror_chatgpt_export_file(&rt, &path, seeded_offset).await;
2348        assert!(
2349            matches!(result, Err(RuntimeError::Internal(_))),
2350            "malformed export must return Internal error, got {result:?}"
2351        );
2352
2353        let stored_offset = cursor_offset(&rt, &path.to_string_lossy()).await;
2354        assert_eq!(
2355            stored_offset,
2356            Some(seeded_offset as i64),
2357            "cursor must remain at the pre-error value"
2358        );
2359        assert_eq!(
2360            count_rows(&rt, "sessions").await,
2361            seeded_sessions,
2362            "no new session rows on parse failure"
2363        );
2364        assert_eq!(
2365            count_rows(&rt, "session_messages").await,
2366            seeded_messages,
2367            "no new message rows on parse failure"
2368        );
2369    }
2370
2371    #[tokio::test]
2372    async fn test_chatgpt_export_over_max_bytes_is_skipped_without_reading() {
2373        // Regression for PACKSESSION-AUD-003 (Medium, "adjacent unbounded
2374        // external-file path"): an export over the configured ceiling must
2375        // be skipped (not `read_to_string`'d, not parsed, not erroring) and
2376        // the cursor must stay untouched so the oversized source is retried
2377        // — and re-warned — on the next tick rather than silently dropped.
2378        let (rt, _dir) = setup().await;
2379        let (_file, path) = write_export_file("[]");
2380
2381        let file_len = std::fs::metadata(&path).unwrap().len();
2382        let max_bytes = 1u64; // smaller than even an empty-array export
2383        assert!(
2384            file_len > max_bytes,
2385            "fixture export must exceed the tiny ceiling"
2386        );
2387
2388        let stats = mirror_chatgpt_export_file_with_max_bytes(&rt, &path, 0, max_bytes)
2389            .await
2390            .expect("an oversized export must be skipped, not error");
2391
2392        assert_eq!(stats.inserted, 0);
2393        assert_eq!(stats.scanned, 0);
2394        assert_eq!(
2395            stats.new_offset, 0,
2396            "cursor must not advance past a skipped oversized export"
2397        );
2398        assert_eq!(
2399            cursor_offset(&rt, &path.to_string_lossy()).await,
2400            None,
2401            "no cursor row should be written for a skipped pass"
2402        );
2403        assert_eq!(count_rows(&rt, "sessions").await, 0);
2404        assert_eq!(count_rows(&rt, "session_messages").await, 0);
2405    }
2406
2407    #[tokio::test]
2408    async fn test_chatgpt_secret_bearing_conversations_json_is_masked() {
2409        // Assembled from fragments at runtime so no credential-shaped literal
2410        // is committed to the repo; matches the AWS-key shape already covered
2411        // by `khive_runtime::secret_gate`'s own detector tests.
2412        let secret_fragment_a = "AKIA";
2413        let secret_fragment_b = "FAKEKEY1234567890";
2414        let secret = format!("{secret_fragment_a}{secret_fragment_b}");
2415        let user_text = format!("here is my key: {secret}");
2416
2417        let conv = json!({
2418            "id": "conv-secret",
2419            "title": "Synthetic Secret",
2420            "current_node": "msg-secret-user",
2421            "mapping": {
2422                "root-secret": {
2423                    "id": "root-secret",
2424                    "message": null,
2425                    "parent": null,
2426                    "children": ["msg-secret-user"]
2427                },
2428                "msg-secret-user": {
2429                    "id": "msg-secret-user",
2430                    "parent": "root-secret",
2431                    "children": [],
2432                    "message": {
2433                        "id": "msg-secret-user",
2434                        "author": {"role": "user"},
2435                        "content": {"content_type": "text", "parts": [user_text]}
2436                    }
2437                }
2438            }
2439        });
2440        let content = serde_json::to_string(&json!([conv])).unwrap();
2441        let (_file, path) = write_export_file(&content);
2442
2443        let (rt, _dir) = setup().await;
2444        let stats = mirror_chatgpt_export_file(&rt, &path, 0)
2445            .await
2446            .expect("secret-bearing content must still ingest, only masked");
2447        assert_eq!(stats.inserted, 1);
2448
2449        let sql = rt.sql();
2450        let mut r = sql.reader().await.expect("reader");
2451        let row = r
2452            .query_row(SqlStatement {
2453                sql: "SELECT text, raw FROM session_messages WHERE session_id='conv-secret'".into(),
2454                params: vec![],
2455                label: None,
2456            })
2457            .await
2458            .expect("query ok")
2459            .expect("message row must exist");
2460        let (stored_text, stored_raw) = match (row.get("text"), row.get("raw")) {
2461            (Some(SqlValue::Text(t)), Some(SqlValue::Text(r))) => (t.clone(), r.clone()),
2462            other => panic!("unexpected text/raw shape: {other:?}"),
2463        };
2464
2465        assert!(
2466            !stored_text.contains(&secret),
2467            "stored text must not contain the raw secret"
2468        );
2469        assert!(
2470            !stored_raw.contains(&secret),
2471            "stored raw must not contain the raw secret"
2472        );
2473        assert!(
2474            stored_text.contains("***MASKED***"),
2475            "stored text must carry the secret_gate redaction marker"
2476        );
2477        assert!(
2478            stored_raw.contains("***MASKED***"),
2479            "stored raw must carry the secret_gate redaction marker"
2480        );
2481    }
2482
2483    /// SS6 invariants #4 ("an ingest error never advances the cursor") and #5
2484    /// ("one transaction per file pass") both rest on the same underlying
2485    /// contract `write_events_and_cursor` depends on: an `atomic_unit` whose
2486    /// closure returns `Err` before its final statement must leave no
2487    /// visible trace of ANY write it made in that pass — including the
2488    /// cursor upsert, which runs last, right before the closure returns
2489    /// `Ok`.
2490    ///
2491    /// The real ingest loop can't be driven into a mid-loop DB error through
2492    /// crafted event data: the `sessions` insert uses `ON CONFLICT(id) DO
2493    /// NOTHING` and the `session_messages` insert uses `INSERT OR IGNORE`,
2494    /// both of which swallow constraint violations by design (that's what
2495    /// makes re-ingest idempotent). So this test drives the same
2496    /// `atomic_unit`/`writer.execute`/`Err`-return path directly — the exact
2497    /// machinery `write_events_and_cursor`'s `?`-propagated errors rely on
2498    /// (ADR-099 D5) — and forces a genuine, non-suppressed SQL error (a
2499    /// `prepare()` failure on a nonexistent table) after a session write AND
2500    /// a cursor advance have already succeeded within the same open unit.
2501    #[tokio::test]
2502    async fn test_mid_transaction_db_error_leaves_no_partial_state_and_cursor_unadvanced() {
2503        let (rt, _dir) = setup().await;
2504        let sql = rt.sql();
2505        let path = std::path::Path::new("/synthetic/mid-tx-probe.json");
2506        let path_owned = path.to_path_buf();
2507
2508        let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
2509            Box::pin(async move {
2510                // First write succeeds — mirrors event 1's session row in a
2511                // multi-event file pass.
2512                writer
2513                    .execute(SqlStatement {
2514                        sql: "INSERT INTO sessions \
2515                              (id, provider_session_id, source, message_count, first_seen_at, last_seen_at, namespace) \
2516                              VALUES('mid-tx-session', 'mid-tx-session', 'chatgpt_export', 0, 1, 1, 'local')"
2517                            .into(),
2518                        params: vec![],
2519                        label: None,
2520                    })
2521                    .await?;
2522
2523                // Cursor advance succeeds too — mirrors `upsert_cursor_on_writer`
2524                // running near the end of `write_events_and_cursor_on_writer`.
2525                upsert_cursor_on_writer(writer, &path_owned, Some("mid-tx-session"), 999, 1)
2526                    .await?;
2527
2528                // Third write fails with a genuine (non-suppressed) SQL error —
2529                // mirrors a mid-loop DB failure on a later event in the same file.
2530                writer
2531                    .execute(SqlStatement {
2532                        sql: "INSERT INTO no_such_table_mid_tx_probe(a) VALUES(1)".into(),
2533                        params: vec![],
2534                        label: None,
2535                    })
2536                    .await?;
2537
2538                Ok(Box::new(()) as Box<dyn std::any::Any + Send>)
2539            })
2540        });
2541
2542        // `atomic_unit` itself must surface the error and roll back the
2543        // whole unit — no explicit `commit()`/`drop()` orchestration is the
2544        // caller's job anymore; the seam owns it.
2545        let result = sql.atomic_unit(op).await;
2546        assert!(
2547            result.is_err(),
2548            "atomic_unit must propagate the forced third-write failure"
2549        );
2550
2551        assert_eq!(
2552            count_rows(&rt, "sessions").await,
2553            0,
2554            "session write must not survive a later error in the same atomic unit"
2555        );
2556        assert_eq!(
2557            cursor_offset(&rt, &path.to_string_lossy()).await,
2558            None,
2559            "cursor must not advance when a later write in the same atomic unit fails"
2560        );
2561    }
2562
2563    /// Build a bare, file-backed, write-queue-enabled `SqlAccess` handle —
2564    /// no `KhiveRuntime`, no `KHIVE_WRITE_QUEUE` env var. Mirrors
2565    /// khive-pack-brain's `fold_gate.rs`/`persist.rs` write-queue-routing
2566    /// tests: `PoolConfig::default()` reads `KHIVE_WRITE_QUEUE` at
2567    /// construction time, and that env var is process-global, so mutating
2568    /// it here would race every other test in this binary that calls
2569    /// `KhiveRuntime::new()` (that binary-wide hazard is exactly what those
2570    /// two tests' doc comments document having hit). A `PoolConfig` literal
2571    /// with `write_queue_enabled: true` sidesteps it entirely — no
2572    /// `#[serial]`, no risk to any other test in this crate.
2573    fn write_queue_pool(db_path: std::path::PathBuf) -> Arc<khive_db::ConnectionPool> {
2574        let pool_cfg = khive_db::PoolConfig {
2575            path: Some(db_path),
2576            write_queue_enabled: true,
2577            ..khive_db::PoolConfig::default()
2578        };
2579        let pool = Arc::new(khive_db::ConnectionPool::new(pool_cfg).expect("pool"));
2580        {
2581            let w_conn = pool.writer().expect("writer");
2582            for stmt in &SESSION_SCHEMA_PLAN_STMTS {
2583                w_conn
2584                    .conn()
2585                    .execute_batch(stmt)
2586                    .expect("session schema stmt");
2587            }
2588        }
2589        pool
2590    }
2591
2592    /// ADR-099 D5 acceptance: the converted `write_events_and_cursor`
2593    /// closure is suspension-free — it drives only synchronous DML through
2594    /// `writer`, so it resolves on its first poll and is `block_on_sync`-safe
2595    /// on the single-writer path. Exercises the real production closure
2596    /// (`write_events_and_cursor_on_writer` via `atomic_unit`) over a
2597    /// write-queue-enabled pool built directly (see `write_queue_pool`), so
2598    /// this proves the actual shipped code — not a stand-in — never
2599    /// suspends: if it ever did, `block_on_sync` would return the "future
2600    /// suspended" error and this call would fail instead of returning `Ok`.
2601    #[tokio::test]
2602    async fn write_events_and_cursor_is_suspension_free_under_single_writer() {
2603        let dir = TempDir::new().expect("tempdir");
2604        let pool = write_queue_pool(dir.path().join("suspend_free.db"));
2605        let sql: Arc<dyn khive_storage::SqlAccess> =
2606            Arc::new(khive_db::SqlBridge::new(Arc::clone(&pool), true));
2607
2608        pool.writer_task_handle()
2609            .unwrap()
2610            .expect("writer task must be spawned with the flag on for a file-backed pool");
2611
2612        let events = vec![parse::parse_cc_line(
2613            r#"{"uuid":"evt-1","sessionId":"suspend-free-session","type":"user","message":{"role":"user","content":"hello"},"cwd":"/tmp","timestamp":"2026-01-01T00:00:00Z"}"#,
2614        )
2615        .expect("line must parse")];
2616
2617        let path = std::path::Path::new("/synthetic/suspend-free.jsonl").to_path_buf();
2618        let now_us = Utc::now().timestamp_micros();
2619        let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
2620            Box::pin(async move {
2621                write_events_and_cursor_on_writer(
2622                    writer,
2623                    &path,
2624                    "claude_code",
2625                    &events,
2626                    1,
2627                    100,
2628                    now_us,
2629                )
2630                .await
2631                .map(|stats| Box::new(stats) as Box<dyn std::any::Any + Send>)
2632                .map_err(|e| {
2633                    khive_storage::StorageError::driver(
2634                        khive_storage::StorageCapability::Sql,
2635                        "test_write_events_and_cursor",
2636                        e,
2637                    )
2638                })
2639            })
2640        });
2641
2642        let boxed = sql
2643            .atomic_unit(op)
2644            .await
2645            .expect("a suspension-free closure must not hit block_on_sync's Pending error");
2646        let stats = *boxed
2647            .downcast::<MirrorStats>()
2648            .expect("op must return MirrorStats");
2649
2650        assert_eq!(stats.inserted, 1, "the one event must be inserted");
2651
2652        let mut reader = sql.reader().await.expect("reader");
2653        let row = khive_storage::SqlReader::query_scalar(
2654            reader.as_mut(),
2655            SqlStatement {
2656                sql: "SELECT COUNT(*) FROM sessions".into(),
2657                params: vec![],
2658                label: None,
2659            },
2660        )
2661        .await
2662        .expect("count query")
2663        .expect("count row");
2664        match row {
2665            SqlValue::Integer(1) => {}
2666            other => panic!("the session row must be committed, got COUNT(*) = {other:?}"),
2667        }
2668    }
2669
2670    /// ADR-099 D5 acceptance ("single-writer concurrency test, mandatory"):
2671    /// with the write queue enabled, concurrent session-mirror ingest
2672    /// (`write_events_and_cursor_on_writer` via `atomic_unit`) and normal
2673    /// write traffic through `SqlBridge::writer()` must not contend at
2674    /// `BEGIN IMMEDIATE` — the converted ingest path routes through the
2675    /// single writer task rather than opening its own standalone
2676    /// transaction (the `begin_tx` hole this ADR closes). Uses the same
2677    /// queue-depth + occupier-parked-on-oneshot technique as
2678    /// khive-pack-brain's `fold_gate_apply_routes_through_writer_task_when_flag_enabled`
2679    /// (a wall-clock/timing probe would be indistinguishable from the
2680    /// flag-off fallback, which also serializes via real SQLite file
2681    /// locking): while an occupier deterministically holds the writer
2682    /// task's one drain slot open, the ingest call must appear in the
2683    /// channel's queue depth rather than opening a second, competing
2684    /// standalone `BEGIN IMMEDIATE`.
2685    #[tokio::test]
2686    async fn session_ingest_routes_through_writer_task_when_flag_enabled() {
2687        let dir = TempDir::new().expect("tempdir");
2688        let pool = write_queue_pool(dir.path().join("concurrency.db"));
2689        let sql: Arc<dyn khive_storage::SqlAccess> =
2690            Arc::new(khive_db::SqlBridge::new(Arc::clone(&pool), true));
2691
2692        let writer_task = pool
2693            .writer_task_handle()
2694            .unwrap()
2695            .expect("writer task must be spawned with the flag on for a file-backed pool");
2696
2697        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
2698        let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
2699        let occupier = {
2700            let writer_task = writer_task.clone();
2701            tokio::spawn(async move {
2702                writer_task
2703                    .send(move |_conn| {
2704                        let _ = started_tx.send(());
2705                        let _ = release_rx.blocking_recv();
2706                        Ok::<(), khive_storage::StorageError>(())
2707                    })
2708                    .await
2709            })
2710        };
2711
2712        started_rx
2713            .await
2714            .expect("occupier must signal it has started running inside the writer task");
2715        assert_eq!(
2716            writer_task.queue_depth(),
2717            0,
2718            "channel must start empty once the occupier has been dequeued and is running"
2719        );
2720
2721        let events = vec![parse::parse_cc_line(
2722            r#"{"uuid":"evt-concurrency-1","sessionId":"concurrency-session","type":"user","message":{"role":"user","content":"hello"},"cwd":"/tmp","timestamp":"2026-01-01T00:00:00Z"}"#,
2723        )
2724        .expect("line must parse")];
2725        let path = std::path::Path::new("/synthetic/concurrency.jsonl").to_path_buf();
2726        let now_us = Utc::now().timestamp_micros();
2727        let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
2728            Box::pin(async move {
2729                write_events_and_cursor_on_writer(
2730                    writer,
2731                    &path,
2732                    "claude_code",
2733                    &events,
2734                    1,
2735                    100,
2736                    now_us,
2737                )
2738                .await
2739                .map(|stats| Box::new(stats) as Box<dyn std::any::Any + Send>)
2740                .map_err(|e| {
2741                    khive_storage::StorageError::driver(
2742                        khive_storage::StorageCapability::Sql,
2743                        "test_session_ingest_concurrency",
2744                        e,
2745                    )
2746                })
2747            })
2748        });
2749
2750        let sql_for_ingest = Arc::clone(&sql);
2751        let ingest_task = tokio::spawn(async move { sql_for_ingest.atomic_unit(op).await });
2752
2753        let mut saw_enqueued = false;
2754        for _ in 0..100 {
2755            if writer_task.queue_depth() >= 1 {
2756                saw_enqueued = true;
2757                break;
2758            }
2759            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2760        }
2761        assert!(
2762            saw_enqueued,
2763            "session-ingest's atomic_unit request never appeared in the writer task's \
2764             channel while the occupier held the single drain slot — the converted ingest \
2765             path is not routing through the shared writer task (a standalone `begin_tx` \
2766             connection would never show up here at all)"
2767        );
2768
2769        release_tx
2770            .send(())
2771            .expect("occupier must still be waiting on the release signal");
2772        occupier
2773            .await
2774            .expect("occupier task must not panic")
2775            .expect("occupier write must succeed");
2776
2777        let boxed = ingest_task
2778            .await
2779            .expect("ingest task must not panic")
2780            .expect("ingest atomic_unit must succeed once the occupier releases the slot");
2781        let stats = *boxed
2782            .downcast::<MirrorStats>()
2783            .expect("op must return MirrorStats");
2784        assert_eq!(stats.inserted, 1, "the ingest event must be committed");
2785    }
2786
2787    /// ADR-099 acceptance ("revert-companion test"): the OLD shape — a
2788    /// closure that issues its own `BEGIN IMMEDIATE` through the writer it
2789    /// was handed, instead of relying on `atomic_unit`'s own transaction —
2790    /// must fail deterministically with a nested-transaction error. This
2791    /// proves the suspension-free / single-transaction-owner assertions
2792    /// above are non-vacuous: the pre-conversion shape (a caller managing
2793    /// its own `BEGIN`/`COMMIT` inside the seam) does NOT silently pass.
2794    /// Built over a write-queue-enabled pool (see `write_queue_pool`) so the
2795    /// closure is deterministically driven through `block_on_sync`'s
2796    /// `InlineWriter` on the real single-writer production path, not the
2797    /// flag-off manual-transaction fallback.
2798    #[tokio::test]
2799    async fn old_shape_manual_begin_immediate_inside_atomic_unit_fails() {
2800        let dir = TempDir::new().expect("tempdir");
2801        let pool = write_queue_pool(dir.path().join("old_shape_begin_immediate.db"));
2802        let sql: Arc<dyn khive_storage::SqlAccess> =
2803            Arc::new(khive_db::SqlBridge::new(Arc::clone(&pool), true));
2804
2805        pool.writer_task_handle()
2806            .unwrap()
2807            .expect("writer task must be spawned with the flag on for a file-backed pool");
2808
2809        let op: khive_storage::AtomicUnitOp = Box::new(move |writer: &mut dyn SqlWriter| {
2810            Box::pin(async move {
2811                // `atomic_unit` already has an open transaction around this
2812                // closure — issuing a second `BEGIN IMMEDIATE` here is
2813                // exactly the old `begin_tx`-shaped mistake this ADR
2814                // retires: a caller managing its own transaction control
2815                // inside a seam that already owns the transaction boundary.
2816                writer
2817                    .execute(SqlStatement {
2818                        sql: "BEGIN IMMEDIATE".into(),
2819                        params: vec![],
2820                        label: None,
2821                    })
2822                    .await?;
2823                Ok(Box::new(()) as Box<dyn std::any::Any + Send>)
2824            })
2825        });
2826
2827        let err = sql.atomic_unit(op).await.expect_err(
2828            "a closure that issues its own BEGIN IMMEDIATE inside atomic_unit must fail with a \
2829             nested-transaction error, not silently succeed",
2830        );
2831        let msg = err.to_string();
2832        assert!(
2833            msg.contains("cannot start a transaction within a transaction"),
2834            "expected the deterministic nested-transaction failure (SQLite's own message for a \
2835             second BEGIN issued inside an already-open transaction), got: {msg}"
2836        );
2837    }
2838}