Skip to main content

swink_agent_memory/
jsonl.rs

1//! JSONL-based session persistence.
2//!
3//! Stores each session as a `.jsonl` file: the first line contains
4//! [`SessionMeta`], and subsequent lines each contain one [`LlmMessage`].
5//!
6//! Concurrent writes to the same session may corrupt the file.
7//! Callers are expected to enforce single-writer access.
8
9use std::io::{self, BufRead, Read, Seek, SeekFrom, Write};
10use std::path::{Path, PathBuf};
11
12use swink_agent::atomic_fs::{atomic_write, atomic_write_unlocked, with_target_lock};
13use swink_agent::{AgentMessage, CustomMessageRegistry, LlmMessage};
14
15use crate::entry::SessionEntry;
16use crate::interrupt::InterruptState;
17use crate::load_options::LoadOptions;
18use crate::meta::SessionMeta;
19#[cfg(not(feature = "search"))]
20use crate::search;
21use crate::search::{SessionHit, SessionSearchOptions};
22use crate::store::SessionStore;
23use crate::time::{format_session_id, now_utc};
24
25const META_LINE_PADDING: usize = 64;
26
27#[derive(Debug, Clone)]
28enum SessionRecord {
29    Llm(Box<LlmMessage>),
30    Custom(serde_json::Value),
31    AppendBegin,
32    Meta(Box<SessionMeta>),
33    State(serde_json::Value),
34}
35
36impl SessionRecord {
37    fn from_message(message: &AgentMessage, session_id: &str) -> Option<Self> {
38        let line = crate::codec::encode_jsonl_message_line(message, session_id)?;
39        match Self::parse(&line).ok()? {
40            Self::Llm(llm) => Some(Self::Llm(llm)),
41            Self::Custom(envelope) => Some(Self::Custom(envelope)),
42            Self::AppendBegin | Self::Meta(_) | Self::State(_) => None,
43        }
44    }
45
46    const fn state(state: serde_json::Value) -> Self {
47        Self::State(state)
48    }
49
50    fn to_json_line(&self) -> io::Result<String> {
51        match self {
52            Self::Llm(message) => serde_json::to_string(message).map_err(io::Error::other),
53            Self::Custom(envelope) => serde_json::to_string(envelope).map_err(io::Error::other),
54            Self::AppendBegin => serde_json::to_string(&serde_json::json!({
55                "_append": true,
56                "phase": "begin"
57            }))
58            .map_err(io::Error::other),
59            Self::Meta(meta) => serde_json::to_string(&serde_json::json!({
60                "_meta": true,
61                "data": meta
62            }))
63            .map_err(io::Error::other),
64            Self::State(state) => serde_json::to_string(&serde_json::json!({
65                "_state": true,
66                "data": state
67            }))
68            .map_err(io::Error::other),
69        }
70    }
71
72    fn parse(line: &str) -> io::Result<Self> {
73        let value: serde_json::Value = serde_json::from_str(line).map_err(io::Error::other)?;
74        if value.get("_state").and_then(serde_json::Value::as_bool) == Some(true) {
75            return Ok(Self::State(
76                value
77                    .get("data")
78                    .cloned()
79                    .unwrap_or(serde_json::Value::Null),
80            ));
81        }
82        if value.get("_append").and_then(serde_json::Value::as_bool) == Some(true) {
83            return Ok(Self::AppendBegin);
84        }
85        if value.get("_meta").and_then(serde_json::Value::as_bool) == Some(true) {
86            return serde_json::from_value::<SessionMeta>(
87                value
88                    .get("data")
89                    .cloned()
90                    .unwrap_or(serde_json::Value::Null),
91            )
92            .map(Box::new)
93            .map(Self::Meta)
94            .map_err(io::Error::other);
95        }
96        if value.get("_custom").and_then(serde_json::Value::as_bool) == Some(true) {
97            return Ok(Self::Custom(value));
98        }
99        serde_json::from_value::<LlmMessage>(value)
100            .map(Box::new)
101            .map(Self::Llm)
102            .map_err(io::Error::other)
103    }
104}
105
106fn session_path(sessions_dir: &Path, id: &str) -> PathBuf {
107    sessions_dir.join(format!("{id}.jsonl"))
108}
109
110fn interrupt_path(sessions_dir: &Path, id: &str) -> PathBuf {
111    sessions_dir.join(format!("{id}.interrupt.json"))
112}
113
114fn with_session_lock<T>(
115    sessions_dir: &Path,
116    id: &str,
117    op: impl FnOnce(&Path, &Path) -> io::Result<T>,
118) -> io::Result<T> {
119    let session = session_path(sessions_dir, id);
120    let interrupt = interrupt_path(sessions_dir, id);
121    with_target_lock(&session, || op(&session, &interrupt))
122}
123
124fn not_found(id: &str) -> io::Error {
125    io::Error::new(io::ErrorKind::NotFound, format!("session not found: {id}"))
126}
127
128fn sequence_conflict(id: &str, expected: u64, actual: u64) -> io::Error {
129    io::Error::new(
130        io::ErrorKind::AlreadyExists,
131        format!("sequence conflict for session {id}: expected {expected}, found {actual}"),
132    )
133}
134
135fn check_sequence_path(path: &Path, id: &str, caller_sequence: u64) -> io::Result<()> {
136    if !path.exists() {
137        return Ok(());
138    }
139    let (stored_meta, _) = read_meta_with_line_len(path, id)?;
140    if stored_meta.sequence != caller_sequence {
141        return Err(sequence_conflict(id, caller_sequence, stored_meta.sequence));
142    }
143    Ok(())
144}
145
146fn empty_file() -> io::Error {
147    io::Error::new(io::ErrorKind::InvalidData, "empty session file")
148}
149
150fn invalid_meta(error: impl std::fmt::Display) -> io::Error {
151    io::Error::new(
152        io::ErrorKind::InvalidData,
153        format!("invalid session metadata: {error}"),
154    )
155}
156
157fn open_session_file(path: &Path, id: &str) -> io::Result<std::fs::File> {
158    let file = std::fs::File::open(path).map_err(|error| {
159        if error.kind() == io::ErrorKind::NotFound {
160            not_found(id)
161        } else {
162            error
163        }
164    })?;
165
166    if file.metadata()?.len() == 0 {
167        return Err(empty_file());
168    }
169
170    Ok(file)
171}
172
173fn read_meta_and_message_lines(path: &Path, id: &str) -> io::Result<(SessionMeta, Vec<String>)> {
174    let file = open_session_file(path, id)?;
175    let mut reader = io::BufReader::new(file);
176
177    let meta_bytes = read_raw_line(&mut reader)?.ok_or_else(empty_file)?;
178    let meta_line = String::from_utf8(meta_bytes).map_err(invalid_meta)?;
179    let mut meta =
180        canonical_meta_for_id(serde_json::from_str(&meta_line).map_err(invalid_meta)?, id);
181
182    // Read message lines as raw bytes and validate UTF-8 per line. A crash
183    // mid-append can truncate the final line inside a multi-byte UTF-8
184    // sequence; `BufRead::lines()` would surface that as an `io::Error` and
185    // abort the whole load before any per-line record classification runs.
186    // Instead, skip the corrupt line with a warning so the rest of the
187    // session is recovered (spec 021 FR-004: partial recovery over total
188    // failure, same as bad-JSON lines in `classify_and_migrate`).
189    let mut remaining_lines = Vec::new();
190    let mut pending_append_lines: Option<Vec<String>> = None;
191    let mut line_num = 1_usize;
192    while let Some(raw) = read_raw_line(&mut reader)? {
193        line_num += 1;
194        let line = match String::from_utf8(raw) {
195            Ok(line) => line,
196            Err(error) => {
197                tracing::warn!(
198                    line = line_num,
199                    error = %error,
200                    "skipping invalid-UTF-8 line in session {id} \
201                     (likely truncated by a crash mid-write)"
202                );
203                continue;
204            }
205        };
206        match SessionRecord::parse(&line) {
207            Ok(SessionRecord::AppendBegin) => {
208                if pending_append_lines.is_some() {
209                    tracing::warn!("discarding uncommitted nested append records in session {id}");
210                }
211                pending_append_lines = Some(Vec::new());
212            }
213            Ok(SessionRecord::Meta(meta_update)) => {
214                if let Some(mut lines) = pending_append_lines.take() {
215                    remaining_lines.append(&mut lines);
216                }
217                meta = canonical_meta_for_id(*meta_update, id);
218            }
219            Ok(SessionRecord::Llm(_) | SessionRecord::Custom(_) | SessionRecord::State(_))
220            | Err(_) => {
221                if let Some(lines) = pending_append_lines.as_mut() {
222                    lines.push(line);
223                } else {
224                    remaining_lines.push(line);
225                }
226            }
227        }
228    }
229    if pending_append_lines.is_some() {
230        tracing::warn!("discarding uncommitted append records in session {id}");
231    }
232
233    Ok((meta, remaining_lines))
234}
235
236/// Read one `\n`-terminated line as raw bytes, without requiring valid UTF-8.
237///
238/// Returns `Ok(None)` at EOF. A trailing `\n` (and preceding `\r`, if any) is
239/// stripped, matching [`BufRead::lines`] semantics. The final line is
240/// returned even when it lacks a terminating newline (e.g. a write cut short
241/// by a crash).
242fn read_raw_line(reader: &mut impl BufRead) -> io::Result<Option<Vec<u8>>> {
243    let mut buf = Vec::new();
244    if reader.read_until(b'\n', &mut buf)? == 0 {
245        return Ok(None);
246    }
247    if buf.last() == Some(&b'\n') {
248        buf.pop();
249        if buf.last() == Some(&b'\r') {
250            buf.pop();
251        }
252    }
253    Ok(Some(buf))
254}
255
256fn extract_state_from_lines(lines: &[String], id: &str) -> io::Result<Option<serde_json::Value>> {
257    for line in lines {
258        if let Some(state) = parse_state_line(line, id)? {
259            return Ok(Some(state));
260        }
261    }
262
263    Ok(None)
264}
265
266/// Reads the authoritative [`SessionMeta`] and the byte length of the padded
267/// first line (used for in-place metadata patches).
268///
269/// The append-in-place protocol commits metadata by appending a
270/// [`SessionRecord::Meta`] record at the tail and only afterwards patches the
271/// padded first line, so after a crash the first line may be stale: the LAST
272/// Meta record in the file wins, falling back to the first line when no Meta
273/// record exists. Meta records fully replace each other (no merging), and
274/// lines that fail to parse (e.g. a torn tail line) are skipped.
275fn read_meta_with_line_len(path: &Path, id: &str) -> io::Result<(SessionMeta, usize)> {
276    let mut first_line = String::new();
277    let file = open_session_file(path, id)?;
278    let mut reader = io::BufReader::new(file);
279    reader.read_line(&mut first_line)?;
280
281    if first_line.is_empty() {
282        return Err(empty_file());
283    }
284
285    let line_len = first_line.len();
286    let meta = canonical_meta_for_id(
287        serde_json::from_str(first_line.trim_end()).map_err(invalid_meta)?,
288        id,
289    );
290    let mut file = reader.into_inner();
291    match find_last_meta_commit(&mut file, line_len as u64)? {
292        Some(meta_update) => Ok((canonical_meta_for_id(meta_update, id), line_len)),
293        None => Ok((meta, line_len)),
294    }
295}
296
297/// Initial tail-window size for [`find_last_meta_commit`]'s backward scan.
298const META_TAIL_WINDOW: u64 = 64 * 1024;
299
300/// Finds the most recent committed [`SessionRecord::Meta`] record after the
301/// first line, or `None` if the file contains no parseable Meta record.
302///
303/// Because every committed append ends with a Meta commit record, the record
304/// we want is almost always within the last few bytes of the file. Rather
305/// than forward-parsing every line (O(file size), which made
306/// `SessionStore::list` a full-corpus scan), this reads geometrically growing
307/// tail windows (starting at [`META_TAIL_WINDOW`]) and scans their complete
308/// lines in reverse; when a window grows to cover the whole body it falls
309/// back to the historical forward line-by-line scan, so the result — last
310/// parseable Meta wins, unparseable lines skipped — is identical.
311///
312/// An invalid-UTF-8 line (e.g. a multi-byte character torn by a crash
313/// mid-write) is skipped with a warning like any other unparseable line,
314/// matching the tolerant forward scan (spec 021 FR-004 addendum / issue
315/// #1067). Invalid UTF-8 that lies before the tail window of a large file
316/// goes unnoticed when a later Meta commit is found, favoring recovery of
317/// the committed metadata.
318fn find_last_meta_commit(
319    file: &mut std::fs::File,
320    body_start: u64,
321) -> io::Result<Option<SessionMeta>> {
322    let file_len = file.metadata()?.len();
323    let body_len = file_len.saturating_sub(body_start);
324
325    let mut window = META_TAIL_WINDOW;
326    while window < body_len {
327        // `window < body_len` guarantees `start > body_start`.
328        let start = file_len - window;
329        file.seek(SeekFrom::Start(start))?;
330        let mut buf = vec![0u8; usize::try_from(window).map_err(io::Error::other)?];
331        file.read_exact(&mut buf)?;
332
333        // The window starts mid-line: bytes before the first newline are a
334        // partial line (they may even split a multi-byte character at the
335        // window boundary), so only the bytes after it are complete lines.
336        if let Some(pos) = buf.iter().position(|&b| b == b'\n')
337            && let Some(meta) = last_meta_in_complete_lines(&buf[pos + 1..])
338        {
339            return Ok(Some(meta));
340        }
341
342        window = window.saturating_mul(2);
343    }
344
345    // Terminal case: the remaining window covers the whole body. Stream
346    // forward line-by-line exactly like the historical implementation so
347    // memory stays bounded, tolerating invalid-UTF-8 lines the same way
348    // the full-load forward scan does.
349    file.seek(SeekFrom::Start(body_start))?;
350    let mut meta = None;
351    let mut reader = io::BufReader::new(file);
352    while let Some(raw) = read_raw_line(&mut reader)? {
353        let Ok(line) = String::from_utf8(raw) else {
354            tracing::warn!(
355                "skipping invalid-UTF-8 line in session meta scan \
356                 (likely truncated by a crash mid-write)"
357            );
358            continue;
359        };
360        if let Ok(SessionRecord::Meta(meta_update)) = SessionRecord::parse(&line) {
361            meta = Some(*meta_update);
362        }
363    }
364    Ok(meta)
365}
366
367/// Scans complete lines in reverse for the last parseable Meta record.
368///
369/// `bytes` must start at a real line boundary and extend to end of file.
370fn last_meta_in_complete_lines(bytes: &[u8]) -> Option<SessionMeta> {
371    // Validate UTF-8 per line: an invalid-UTF-8 line (e.g. a torn tail line
372    // that truncates a multi-byte character) is skipped like any other
373    // unparseable line, matching the tolerant forward scan (spec 021 FR-004
374    // addendum / issue #1067).
375    for line in bytes.split(|&b| b == b'\n').rev() {
376        let line = line.strip_suffix(b"\r").unwrap_or(line);
377        if line.is_empty() {
378            continue;
379        }
380        let Ok(text) = std::str::from_utf8(line) else {
381            tracing::warn!(
382                "skipping invalid-UTF-8 line in session meta scan \
383                 (likely truncated by a crash mid-write)"
384            );
385            continue;
386        };
387        if let Ok(SessionRecord::Meta(meta_update)) = SessionRecord::parse(text) {
388            return Some(*meta_update);
389        }
390    }
391    None
392}
393
394fn canonical_meta_for_id(mut meta: SessionMeta, id: &str) -> SessionMeta {
395    meta.id = id.to_string();
396    meta
397}
398
399fn rewrite_session_file_locked(
400    path: &Path,
401    meta: &SessionMeta,
402    lines: &[String],
403) -> io::Result<()> {
404    atomic_write_unlocked(path, |writer| {
405        write_meta_line(writer, meta, META_LINE_PADDING)?;
406
407        for line in lines {
408            if !line.is_empty() {
409                writeln!(writer, "{line}")?;
410            }
411        }
412        Ok(())
413    })
414}
415
416fn preserve_existing_lines(
417    path: &Path,
418    id: &str,
419    should_preserve: impl Fn(&str) -> bool,
420) -> io::Result<Vec<String>> {
421    if !path.exists() {
422        return Ok(Vec::new());
423    }
424
425    let (_, lines) = read_meta_and_message_lines(path, id)?;
426    Ok(lines
427        .into_iter()
428        .filter(|line| should_preserve(line))
429        .collect())
430}
431
432fn preserve_for_message_save(line: &str) -> bool {
433    match SessionRecord::parse(line) {
434        Ok(SessionRecord::State(_)) => true,
435        Ok(
436            SessionRecord::AppendBegin
437            | SessionRecord::Llm(_)
438            | SessionRecord::Custom(_)
439            | SessionRecord::Meta(_),
440        ) => false,
441        Err(_) => matches!(
442            SessionEntry::parse(line),
443            Ok(entry) if !matches!(entry, SessionEntry::Message(_))
444        ),
445    }
446}
447
448fn preserve_for_entry_save(line: &str) -> bool {
449    matches!(
450        SessionRecord::parse(line),
451        Ok(SessionRecord::State(_) | SessionRecord::Custom(_))
452    )
453}
454
455fn save_messages_with_hooks<AfterValidation, WriteOp>(
456    path: &Path,
457    id: &str,
458    meta: &SessionMeta,
459    messages: &[AgentMessage],
460    after_validation: AfterValidation,
461    write_op: WriteOp,
462) -> io::Result<()>
463where
464    AfterValidation: FnOnce() -> io::Result<()>,
465    WriteOp: FnOnce(&Path, &SessionMeta, &[AgentMessage], &str) -> io::Result<()>,
466{
467    with_target_lock(path, || {
468        check_sequence_path(path, id, meta.sequence)?;
469        after_validation()?;
470
471        let mut write_meta = meta.clone();
472        write_meta.id = id.to_string();
473        write_meta.updated_at = now_utc();
474        write_meta.sequence += 1;
475        write_op(path, &write_meta, messages, id)
476    })
477}
478
479fn write_messages_locked(
480    path: &Path,
481    meta: &SessionMeta,
482    messages: &[AgentMessage],
483    id: &str,
484) -> io::Result<()> {
485    let preserved_lines = preserve_existing_lines(path, id, preserve_for_message_save)?;
486
487    write_messages_with_preserved_lines(path, meta, messages, id, &preserved_lines)
488}
489
490fn write_messages_with_preserved_lines(
491    path: &Path,
492    meta: &SessionMeta,
493    messages: &[AgentMessage],
494    id: &str,
495    preserved_lines: &[String],
496) -> io::Result<()> {
497    atomic_write_unlocked(path, |writer| {
498        write_meta_line(writer, meta, META_LINE_PADDING)?;
499
500        for msg in messages {
501            if let Some(record) = SessionRecord::from_message(msg, id) {
502                writer.write_all(record.to_json_line()?.as_bytes())?;
503                writeln!(writer)?;
504            }
505        }
506        for line in preserved_lines {
507            if !line.is_empty() {
508                writeln!(writer, "{line}")?;
509            }
510        }
511        Ok(())
512    })
513}
514
515fn write_meta_line(
516    writer: &mut impl Write,
517    meta: &SessionMeta,
518    padding: usize,
519) -> io::Result<usize> {
520    let line = serde_json::to_string(meta).map_err(io::Error::other)?;
521    writer.write_all(line.as_bytes())?;
522    for _ in 0..padding {
523        writer.write_all(b" ")?;
524    }
525    writeln!(writer)?;
526    Ok(line.len() + padding + 1)
527}
528
529fn write_meta_line_in_place(
530    file: &mut std::fs::File,
531    line: &str,
532    existing_line_len: usize,
533) -> io::Result<bool> {
534    if line.len() + 1 > existing_line_len {
535        return Ok(false);
536    }
537
538    file.seek(SeekFrom::Start(0))?;
539    file.write_all(line.as_bytes())?;
540    for _ in line.len()..existing_line_len - 1 {
541        file.write_all(b" ")?;
542    }
543    file.write_all(b"\n")?;
544    Ok(true)
545}
546
547fn append_records_in_place(
548    path: &Path,
549    meta: &SessionMeta,
550    meta_line_len: usize,
551    record_lines: &[String],
552) -> io::Result<bool> {
553    append_records_in_place_with_hook(path, meta, meta_line_len, record_lines, |_| Ok(()))
554}
555
556fn append_records_in_place_with_hook(
557    path: &Path,
558    meta: &SessionMeta,
559    meta_line_len: usize,
560    record_lines: &[String],
561    after_commit_append: impl FnOnce(&mut std::fs::File) -> io::Result<()>,
562) -> io::Result<bool> {
563    append_records_in_place_with_hooks(
564        path,
565        meta,
566        meta_line_len,
567        record_lines,
568        |_| Ok(()),
569        after_commit_append,
570    )
571}
572
573fn append_records_in_place_with_hooks(
574    path: &Path,
575    meta: &SessionMeta,
576    meta_line_len: usize,
577    record_lines: &[String],
578    after_records_append: impl FnOnce(&mut std::fs::File) -> io::Result<()>,
579    after_commit_append: impl FnOnce(&mut std::fs::File) -> io::Result<()>,
580) -> io::Result<bool> {
581    let meta_line = serde_json::to_string(meta).map_err(io::Error::other)?;
582    if meta_line.len() + 1 > meta_line_len {
583        return Ok(false);
584    }
585
586    let mut file = std::fs::OpenOptions::new()
587        .read(true)
588        .write(true)
589        .open(path)?;
590
591    file.seek(SeekFrom::End(0))?;
592    writeln!(file, "{}", SessionRecord::AppendBegin.to_json_line()?)?;
593    for line in record_lines {
594        if !line.is_empty() {
595            writeln!(file, "{line}")?;
596        }
597    }
598    file.flush()?;
599    after_records_append(&mut file)?;
600    writeln!(
601        file,
602        "{}",
603        SessionRecord::Meta(Box::new(meta.clone())).to_json_line()?
604    )?;
605    file.flush()?;
606    after_commit_append(&mut file)?;
607
608    write_meta_line_in_place(&mut file, &meta_line, meta_line_len)?;
609    file.flush()?;
610    Ok(true)
611}
612
613fn upsert_state_line(
614    lines: &mut Vec<String>,
615    session_id: &str,
616    state: &serde_json::Value,
617) -> io::Result<()> {
618    let state_line = SessionRecord::state(state.clone()).to_json_line()?;
619    if let Some(line) = find_record_line_mut(lines.as_mut_slice(), session_id, |record| {
620        matches!(record, SessionRecord::State(_))
621    }) {
622        line.clone_from(&state_line);
623    } else {
624        lines.push(state_line);
625    }
626    Ok(())
627}
628
629fn parse_state_line(line: &str, id: &str) -> io::Result<Option<serde_json::Value>> {
630    let value: serde_json::Value = match serde_json::from_str(line) {
631        Ok(value) => value,
632        Err(error) => {
633            if line.contains("\"_state\"") {
634                return Err(io::Error::new(
635                    io::ErrorKind::InvalidData,
636                    format!("corrupted state line in session {id}: {error}"),
637                ));
638            }
639            return Ok(None);
640        }
641    };
642
643    let Some(state_marker) = value.get("_state") else {
644        return Ok(None);
645    };
646
647    if state_marker.as_bool() != Some(true) {
648        return Err(io::Error::new(
649            io::ErrorKind::InvalidData,
650            format!("invalid state marker in session {id}: expected `_state: true`"),
651        ));
652    }
653
654    Ok(Some(
655        value
656            .get("data")
657            .cloned()
658            .unwrap_or(serde_json::Value::Null),
659    ))
660}
661
662fn append_records(
663    path: &Path,
664    id: &str,
665    records: impl IntoIterator<Item = SessionRecord>,
666) -> io::Result<()> {
667    append_records_with_rewrite(path, id, records, rewrite_session_file_locked)
668}
669
670fn append_records_with_rewrite<F>(
671    path: &Path,
672    id: &str,
673    records: impl IntoIterator<Item = SessionRecord>,
674    rewrite_fn: F,
675) -> io::Result<()>
676where
677    F: FnOnce(&Path, &SessionMeta, &[String]) -> io::Result<()>,
678{
679    with_target_lock(path, || {
680        let (mut meta, meta_line_len) = read_meta_with_line_len(path, id)?;
681        meta.updated_at = now_utc();
682        meta.sequence += 1;
683
684        let record_lines = records
685            .into_iter()
686            .map(|record| record.to_json_line())
687            .collect::<io::Result<Vec<_>>>()?;
688
689        if append_records_in_place(path, &meta, meta_line_len, &record_lines)? {
690            return Ok(());
691        }
692
693        let (_, mut existing_lines) = read_meta_and_message_lines(path, id)?;
694        existing_lines.extend(record_lines);
695        rewrite_fn(path, &meta, &existing_lines)
696    })
697}
698
699fn find_record_line_mut<'a>(
700    lines: &'a mut [String],
701    session_id: &str,
702    predicate: impl Fn(&SessionRecord) -> bool,
703) -> Option<&'a mut String> {
704    for line in lines {
705        match SessionRecord::parse(line) {
706            Ok(record) if predicate(&record) => return Some(line),
707            Ok(_) => {}
708            Err(error) => {
709                tracing::warn!(
710                    error = %error,
711                    "skipping unparseable line while scanning session {session_id}"
712                );
713            }
714        }
715    }
716    None
717}
718
719/// Validate a session ID, rejecting unsafe filesystem characters.
720///
721/// Rejects IDs containing `/`, `\`, `..`, `:`, or ASCII control characters.
722fn validate_session_id(id: &str) -> io::Result<()> {
723    if id.is_empty() {
724        return Err(io::Error::new(
725            io::ErrorKind::InvalidInput,
726            "session ID must not be empty",
727        ));
728    }
729    if id.contains('/')
730        || id.contains('\\')
731        || id.contains("..")
732        || id.chars().any(|c| c == ':' || c.is_ascii_control())
733    {
734        return Err(io::Error::new(
735            io::ErrorKind::InvalidInput,
736            format!("session ID contains unsafe characters: {id:?}"),
737        ));
738    }
739    Ok(())
740}
741
742/// JSONL file-based session store.
743///
744/// Each session is a single `.jsonl` file in the configured directory.
745/// Line 1 is [`SessionMeta`] (JSON), lines 2+ are one [`LlmMessage`] each.
746///
747/// Concurrent writes to the same session may corrupt the file.
748/// Callers are expected to enforce single-writer access.
749///
750/// With the `search` feature enabled, a tantivy index is maintained in
751/// `<sessions_dir>/.search_index/` and used by
752/// [`SessionStore::search()`].  Without the feature, search falls back to a
753/// linear scan of all JSONL files.
754pub struct JsonlSessionStore {
755    sessions_dir: PathBuf,
756    migrators: Vec<Box<dyn crate::migrate::SessionMigrator>>,
757    /// Lazily-opened tantivy index.  Populated on first `search()` call (or
758    /// via `open_search_index()`).  Only present when the `search` feature is
759    /// enabled.
760    ///
761    /// Uses `Mutex<Option<...>>` rather than `OnceLock` because we need
762    /// fallible initialization (`OnceLock::get_or_try_init` is not stable).
763    #[cfg(feature = "search")]
764    tantivy_index: std::sync::Mutex<Option<crate::search::index::TantivyIndex>>,
765    /// Tracks whether the index has been initially populated from all JSONL
766    /// files on first search.
767    #[cfg(feature = "search")]
768    index_built: std::sync::Mutex<bool>,
769}
770
771impl JsonlSessionStore {
772    /// Create a new store rooted at the given directory.
773    ///
774    /// Creates the directory (and parents) if it does not exist.
775    pub fn new(sessions_dir: PathBuf) -> io::Result<Self> {
776        std::fs::create_dir_all(&sessions_dir)?;
777        Ok(Self {
778            sessions_dir,
779            migrators: Vec::new(),
780            #[cfg(feature = "search")]
781            tantivy_index: std::sync::Mutex::new(None),
782            #[cfg(feature = "search")]
783            index_built: std::sync::Mutex::new(false),
784        })
785    }
786
787    /// Open (or create) the tantivy search index eagerly.
788    ///
789    /// Normally the index is opened lazily on the first `search()` call.
790    /// Call this method if you want to detect index-creation errors at startup
791    /// rather than at search time.
792    ///
793    /// Only available with the `search` feature.
794    #[cfg(feature = "search")]
795    pub fn open_search_index(&self) -> io::Result<()> {
796        self.with_tantivy_index(|_| Ok(()))
797    }
798
799    /// Build (or rebuild) the tantivy index from all current JSONL files.
800    ///
801    /// Existing index data is replaced.  Use this after bulk-importing sessions
802    /// outside of the store API.
803    ///
804    /// Only available with the `search` feature.
805    #[cfg(feature = "search")]
806    pub fn rebuild_search_index(&self) -> io::Result<()> {
807        self.with_tantivy_index(|index| {
808            // Wipe all existing docs so sessions removed outside the store API
809            // don't produce ghost hits after the rebuild.
810            index.clear_all()?;
811            for meta in self.list()? {
812                let (_, entries) = self.load_entries(&meta.id)?;
813                index.index_session(&meta, &entries)?;
814            }
815            Ok(())
816        })
817    }
818
819    /// Obtain a snapshot clone of the tantivy index (creating it if needed),
820    /// then call `f` with it.
821    ///
822    /// Using a clone of `TantivyIndex` is safe because it is `Arc`-based
823    /// internally.
824    #[cfg(feature = "search")]
825    fn with_tantivy_index<T>(
826        &self,
827        f: impl FnOnce(&crate::search::index::TantivyIndex) -> io::Result<T>,
828    ) -> io::Result<T> {
829        let index_clone = {
830            let mut guard = self.tantivy_index.lock().unwrap_or_else(|e| e.into_inner());
831            if guard.is_none() {
832                *guard = Some(crate::search::index::TantivyIndex::open_or_create(
833                    &self.sessions_dir,
834                )?);
835            }
836            guard.as_ref().expect("just set").clone()
837        };
838        f(&index_clone)
839    }
840
841    #[cfg(feature = "search")]
842    fn active_tantivy_index(&self) -> Option<crate::search::index::TantivyIndex> {
843        self.tantivy_index
844            .lock()
845            .unwrap_or_else(|e| e.into_inner())
846            .clone()
847    }
848
849    /// Best-effort full re-index of one session in an already-open index.
850    ///
851    /// Reloads the session from disk and replaces every document for it, so
852    /// this is O(session).  It is the right tool for rewrite paths (`save`,
853    /// `save_full`) where existing content may have changed wholesale;
854    /// append paths must use [`Self::append_to_active_search_index`] instead.
855    #[cfg(feature = "search")]
856    fn refresh_active_search_index(&self, id: &str, operation: &str) {
857        let Some(index) = self.active_tantivy_index() else {
858            return;
859        };
860
861        match self.load_entries(id) {
862            Ok((meta, entries)) => {
863                if let Err(err) = index.index_session(&meta, &entries) {
864                    tracing::warn!(
865                        session_id = %id,
866                        error = %err,
867                        operation,
868                        "failed to update search index after session mutation"
869                    );
870                }
871            }
872            Err(err) => {
873                tracing::warn!(
874                    session_id = %id,
875                    error = %err,
876                    operation,
877                    "failed to load session for search index refresh"
878                );
879            }
880        }
881    }
882
883    /// Best-effort incremental index update for append paths.
884    ///
885    /// Adds documents for only the newly appended `entries` instead of
886    /// reloading and re-indexing the whole session, making the index cost of
887    /// an append O(new entries) rather than O(session).  Rewrite paths must
888    /// keep using [`Self::refresh_active_search_index`] for its replacement
889    /// semantics.
890    #[cfg(feature = "search")]
891    fn append_to_active_search_index(
892        &self,
893        id: &str,
894        meta: &SessionMeta,
895        entries: &[SessionEntry],
896    ) {
897        let Some(index) = self.active_tantivy_index() else {
898            return;
899        };
900        if let Err(err) = index.append_session_entries(meta, entries) {
901            tracing::warn!(
902                session_id = %id,
903                error = %err,
904                "failed to append entries to search index after session append"
905            );
906        }
907    }
908
909    /// Register session migrators for automatic schema upgrades on load.
910    #[must_use]
911    pub fn with_migrators(
912        mut self,
913        migrators: Vec<Box<dyn crate::migrate::SessionMigrator>>,
914    ) -> Self {
915        self.migrators = migrators;
916        self
917    }
918
919    /// Default sessions directory: `<config_dir>/swink-agent/sessions`.
920    ///
921    /// Returns `None` if the platform config directory cannot be determined.
922    pub fn default_dir() -> Option<PathBuf> {
923        dirs::config_dir().map(|d| d.join("swink-agent").join("sessions"))
924    }
925
926    /// Generate a new unique session ID using a UTC timestamp plus UUID suffix.
927    pub fn new_session_id() -> String {
928        format_session_id()
929    }
930}
931
932impl SessionStore for JsonlSessionStore {
933    fn save(&self, id: &str, meta: &SessionMeta, messages: &[AgentMessage]) -> io::Result<()> {
934        validate_session_id(id)?;
935        let path = session_path(&self.sessions_dir, id);
936        save_messages_with_hooks(&path, id, meta, messages, || Ok(()), write_messages_locked)?;
937        #[cfg(feature = "search")]
938        self.refresh_active_search_index(id, "save");
939        Ok(())
940    }
941
942    fn save_full(
943        &self,
944        id: &str,
945        meta: &SessionMeta,
946        messages: &[AgentMessage],
947        state: &serde_json::Value,
948    ) -> io::Result<SessionMeta> {
949        validate_session_id(id)?;
950
951        let path = session_path(&self.sessions_dir, id);
952        let persisted_meta = with_target_lock(&path, || {
953            check_sequence_path(&path, id, meta.sequence)?;
954
955            let mut write_meta = meta.clone();
956            write_meta.id = id.to_string();
957            write_meta.updated_at = now_utc();
958            write_meta.sequence += 1;
959
960            let mut preserved_lines =
961                preserve_existing_lines(&path, id, preserve_for_message_save)?;
962            upsert_state_line(&mut preserved_lines, id, state)?;
963            write_messages_with_preserved_lines(
964                &path,
965                &write_meta,
966                messages,
967                id,
968                &preserved_lines,
969            )?;
970
971            Ok(write_meta)
972        })?;
973
974        #[cfg(feature = "search")]
975        self.refresh_active_search_index(id, "save_full");
976
977        Ok(persisted_meta)
978    }
979
980    fn append(&self, id: &str, messages: &[AgentMessage]) -> io::Result<()> {
981        validate_session_id(id)?;
982
983        let path = session_path(&self.sessions_dir, id);
984        append_records(
985            &path,
986            id,
987            messages
988                .iter()
989                .filter_map(|msg| SessionRecord::from_message(msg, id)),
990        )?;
991
992        // Index only the newly appended messages (best-effort). Custom
993        // messages are not `SessionEntry`s and were never indexed by the
994        // previous full-refresh either, so only LLM messages are converted.
995        #[cfg(feature = "search")]
996        if self.active_tantivy_index().is_some() {
997            let entries: Vec<SessionEntry> = messages
998                .iter()
999                .filter_map(|msg| match msg {
1000                    AgentMessage::Llm(llm) => Some(SessionEntry::Message(llm.clone())),
1001                    // Custom (and any future) variants are not `SessionEntry`s
1002                    // and were never indexed by the previous full refresh.
1003                    _ => None,
1004                })
1005                .collect();
1006            if !entries.is_empty() {
1007                match read_meta_with_line_len(&path, id) {
1008                    Ok((meta, _)) => self.append_to_active_search_index(id, &meta, &entries),
1009                    Err(err) => tracing::warn!(
1010                        session_id = %id,
1011                        error = %err,
1012                        "failed to read session meta for search index append"
1013                    ),
1014                }
1015            }
1016        }
1017        Ok(())
1018    }
1019
1020    fn load(
1021        &self,
1022        id: &str,
1023        registry: Option<&CustomMessageRegistry>,
1024    ) -> io::Result<(SessionMeta, Vec<AgentMessage>)> {
1025        validate_session_id(id)?;
1026
1027        let path = session_path(&self.sessions_dir, id);
1028        with_target_lock(&path, || {
1029            let (meta, lines) = read_meta_and_message_lines(&path, id)?;
1030            let (meta, classified) = self.classify_and_migrate(meta, lines, id)?;
1031            Ok((meta, classified_lines_to_messages(classified, registry, id)))
1032        })
1033    }
1034
1035    fn load_full(
1036        &self,
1037        id: &str,
1038        registry: Option<&CustomMessageRegistry>,
1039    ) -> io::Result<(SessionMeta, Vec<AgentMessage>, Option<serde_json::Value>)> {
1040        validate_session_id(id)?;
1041
1042        let path = session_path(&self.sessions_dir, id);
1043        with_target_lock(&path, || {
1044            let (meta, lines) = read_meta_and_message_lines(&path, id)?;
1045            let state = extract_state_from_lines(&lines, id)?;
1046            let (meta, classified) = self.classify_and_migrate(meta, lines, id)?;
1047            let messages = classified_lines_to_messages(classified, registry, id);
1048            Ok((meta, messages, state))
1049        })
1050    }
1051
1052    fn list(&self) -> io::Result<Vec<SessionMeta>> {
1053        let mut sessions = Vec::new();
1054
1055        let entries = std::fs::read_dir(&self.sessions_dir)?;
1056        for entry in entries {
1057            let entry = entry?;
1058            let path = entry.path();
1059            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
1060                continue;
1061            }
1062
1063            let read_meta = with_target_lock(&path, || {
1064                let Some(file_id) = path.file_stem().and_then(|stem| stem.to_str()) else {
1065                    return Ok(None);
1066                };
1067                validate_session_id(file_id)?;
1068                read_meta_with_line_len(&path, file_id)
1069                    .map(|(meta, _)| Some((file_id.to_string(), meta)))
1070            });
1071
1072            match read_meta {
1073                Ok(Some((file_id, meta))) => {
1074                    sessions.push(canonical_meta_for_id(meta, &file_id));
1075                }
1076                Ok(None) => {}
1077                Err(error) => {
1078                    tracing::warn!(
1079                        path = %path.display(),
1080                        error = %error,
1081                        "skipping unreadable session file"
1082                    );
1083                }
1084            }
1085        }
1086
1087        sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
1088        Ok(sessions)
1089    }
1090
1091    fn delete(&self, id: &str) -> io::Result<()> {
1092        validate_session_id(id)?;
1093        with_session_lock(&self.sessions_dir, id, |path, int_path| {
1094            std::fs::remove_file(path)?;
1095            // Cascade-delete the interrupt file if it exists.
1096            if int_path.exists() {
1097                std::fs::remove_file(int_path)?;
1098            }
1099            Ok(())
1100        })?;
1101
1102        // Remove from tantivy index (best-effort).
1103        #[cfg(feature = "search")]
1104        {
1105            let id_owned = id.to_string();
1106            let _ = self.with_tantivy_index(|index| {
1107                if let Err(err) = index.delete_session(&id_owned) {
1108                    tracing::warn!(
1109                        session_id = %id_owned,
1110                        error = %err,
1111                        "failed to remove session from search index after delete"
1112                    );
1113                }
1114                Ok(())
1115            });
1116        }
1117
1118        Ok(())
1119    }
1120
1121    fn save_state(&self, id: &str, state: &serde_json::Value) -> io::Result<()> {
1122        validate_session_id(id)?;
1123
1124        let path = session_path(&self.sessions_dir, id);
1125        with_target_lock(&path, || {
1126            if !path.exists() {
1127                return Err(not_found(id));
1128            }
1129
1130            let (mut meta, mut lines) = read_meta_and_message_lines(&path, id)?;
1131            meta.updated_at = now_utc();
1132            meta.sequence += 1;
1133            upsert_state_line(&mut lines, id, state)?;
1134            rewrite_session_file_locked(&path, &meta, &lines)
1135        })
1136    }
1137
1138    fn load_state(&self, id: &str) -> io::Result<Option<serde_json::Value>> {
1139        validate_session_id(id)?;
1140
1141        let path = session_path(&self.sessions_dir, id);
1142        if !path.exists() {
1143            return Ok(None);
1144        }
1145
1146        with_target_lock(&path, || {
1147            let (_, lines) = read_meta_and_message_lines(&path, id)?;
1148            extract_state_from_lines(&lines, id)
1149        })
1150    }
1151
1152    fn save_interrupt(&self, id: &str, state: &InterruptState) -> io::Result<()> {
1153        validate_session_id(id)?;
1154        with_session_lock(&self.sessions_dir, id, |session, path| {
1155            if !session.exists() {
1156                return Err(not_found(id));
1157            }
1158            atomic_write(path, |writer| {
1159                serde_json::to_writer_pretty(&mut *writer, state).map_err(io::Error::other)
1160            })
1161        })
1162    }
1163
1164    fn load_interrupt(&self, id: &str) -> io::Result<Option<InterruptState>> {
1165        validate_session_id(id)?;
1166        with_session_lock(&self.sessions_dir, id, |session, path| {
1167            if !session.exists() || !path.exists() {
1168                return Ok(None);
1169            }
1170            let contents = std::fs::read_to_string(path)?;
1171            let state: InterruptState = serde_json::from_str(&contents).map_err(|e| {
1172                io::Error::new(
1173                    io::ErrorKind::InvalidData,
1174                    format!("corrupted interrupt file for session {id}: {e}"),
1175                )
1176            })?;
1177            Ok(Some(state))
1178        })
1179    }
1180
1181    fn clear_interrupt(&self, id: &str) -> io::Result<()> {
1182        validate_session_id(id)?;
1183        with_session_lock(&self.sessions_dir, id, |_session, path| {
1184            if path.exists() {
1185                std::fs::remove_file(path)?;
1186            }
1187            Ok(())
1188        })
1189    }
1190
1191    fn load_with_options(
1192        &self,
1193        id: &str,
1194        options: &LoadOptions,
1195    ) -> io::Result<(SessionMeta, Vec<SessionEntry>)> {
1196        let (meta, mut entries) = self.load_entries(id)?;
1197
1198        // Filter by entry type
1199        if let Some(ref types) = options.entry_types {
1200            entries.retain(|entry| types.iter().any(|t| t == entry.entry_type_name()));
1201        }
1202
1203        // Filter by timestamp (entry timestamps are epoch seconds)
1204        if let Some(after) = options.after_timestamp {
1205            let after_secs = after.timestamp().cast_unsigned();
1206            entries.retain(|entry| entry.timestamp().is_some_and(|ts| ts > after_secs));
1207        }
1208
1209        // Truncate to last N
1210        if let Some(n) = options.last_n_entries
1211            && entries.len() > n
1212        {
1213            entries.drain(..entries.len() - n);
1214        }
1215
1216        Ok((meta, entries))
1217    }
1218
1219    fn search(&self, query: &str, options: &SessionSearchOptions) -> io::Result<Vec<SessionHit>> {
1220        // When the `search` feature is enabled, delegate to the tantivy index.
1221        // The index is built lazily on the first call and kept warm for
1222        // subsequent calls.
1223        #[cfg(feature = "search")]
1224        return self.with_tantivy_index(|index| {
1225            self.build_index_if_empty(index)?;
1226            index.search(query, options)
1227        });
1228
1229        // Fallback linear scan (no `search` feature).
1230        #[cfg(not(feature = "search"))]
1231        linear_search(self, query, options)
1232    }
1233}
1234
1235/// Linear (no-index) search implementation used when the `search` feature is
1236/// not enabled.
1237#[cfg(not(feature = "search"))]
1238fn linear_search(
1239    store: &JsonlSessionStore,
1240    query: &str,
1241    options: &SessionSearchOptions,
1242) -> io::Result<Vec<SessionHit>> {
1243    let terms = search::query_terms(query);
1244    let limit = options.limit();
1245    if terms.is_empty() || limit == 0 {
1246        return Ok(Vec::new());
1247    }
1248
1249    let session_ids = if let Some(ids) = &options.session_ids {
1250        ids.iter()
1251            .map(|id| {
1252                validate_session_id(id)?;
1253                Ok(id.clone())
1254            })
1255            .collect::<io::Result<Vec<_>>>()?
1256    } else {
1257        store
1258            .list()?
1259            .into_iter()
1260            .map(|meta| meta.id)
1261            .collect::<Vec<_>>()
1262    };
1263
1264    let mut hits = Vec::new();
1265    for id in session_ids {
1266        let (meta, entries) = store.load_entries(&id)?;
1267        for entry in entries {
1268            if !search::entry_matches_type(&entry, options)
1269                || !search::entry_matches_time_range(&entry, options)
1270            {
1271                continue;
1272            }
1273            let Some((relevance, snippet)) = search::search_entry(&entry, &terms) else {
1274                continue;
1275            };
1276            hits.push(SessionHit::new(
1277                meta.id.clone(),
1278                meta.title.clone(),
1279                entry,
1280                relevance,
1281                snippet,
1282            ));
1283        }
1284    }
1285
1286    hits.sort_by(|left, right| {
1287        right
1288            .score
1289            .cmp(&left.score)
1290            .then_with(|| right.entry.timestamp().cmp(&left.entry.timestamp()))
1291            .then_with(|| left.session_id.cmp(&right.session_id))
1292    });
1293    hits.truncate(limit);
1294    Ok(hits)
1295}
1296
1297#[cfg(feature = "search")]
1298impl JsonlSessionStore {
1299    /// Index all sessions if the initial build has not yet been done.
1300    ///
1301    /// This provides lazy-build-on-first-search semantics: the first `search()`
1302    /// call populates the index from all existing JSONL files; subsequent calls
1303    /// find the index already populated.
1304    fn build_index_if_empty(&self, index: &crate::search::index::TantivyIndex) -> io::Result<()> {
1305        let already_built = {
1306            let guard = self.index_built.lock().unwrap_or_else(|e| e.into_inner());
1307            *guard
1308        };
1309        if already_built {
1310            return Ok(());
1311        }
1312        // Populate from all current sessions.
1313        for meta in self.list()? {
1314            let (_, entries) = self.load_entries(&meta.id)?;
1315            index.index_session(&meta, &entries)?;
1316        }
1317        *self.index_built.lock().unwrap_or_else(|e| e.into_inner()) = true;
1318        Ok(())
1319    }
1320}
1321
1322impl JsonlSessionStore {
1323    /// Save a session with rich entry types.
1324    ///
1325    /// Lines 2+ are [`SessionEntry`] values serialized with an `entry_type` tag.
1326    ///
1327    /// When the `search` feature is enabled, the tantivy index is updated
1328    /// after the file is written (best-effort — index errors are logged but
1329    /// do not fail the save).
1330    pub fn save_entries(
1331        &self,
1332        id: &str,
1333        meta: &SessionMeta,
1334        entries: &[SessionEntry],
1335    ) -> io::Result<()> {
1336        validate_session_id(id)?;
1337        let path = session_path(&self.sessions_dir, id);
1338        #[allow(unused_variables)]
1339        let write_meta = with_target_lock(&path, || {
1340            check_sequence_path(&path, id, meta.sequence)?;
1341
1342            // Increment sequence for the write
1343            let mut write_meta = meta.clone();
1344            write_meta.id = id.to_string();
1345            write_meta.updated_at = now_utc();
1346            write_meta.sequence += 1;
1347            let preserved_lines = preserve_existing_lines(&path, id, preserve_for_entry_save)?;
1348
1349            atomic_write_unlocked(&path, |writer| {
1350                // First line: metadata
1351                serde_json::to_writer(&mut *writer, &write_meta).map_err(io::Error::other)?;
1352                writeln!(writer)?;
1353
1354                // Subsequent lines: one SessionEntry per line
1355                for entry in entries {
1356                    serde_json::to_writer(&mut *writer, entry).map_err(io::Error::other)?;
1357                    writeln!(writer)?;
1358                }
1359                for line in &preserved_lines {
1360                    if !line.is_empty() {
1361                        writeln!(writer, "{line}")?;
1362                    }
1363                }
1364                Ok(())
1365            })?;
1366            Ok(write_meta)
1367        })?;
1368
1369        // Update tantivy index (best-effort).
1370        #[cfg(feature = "search")]
1371        {
1372            let entries_for_index: Vec<_> = entries.to_vec();
1373            let meta_for_index = write_meta;
1374            let _ = self.with_tantivy_index(|index| {
1375                if let Err(err) = index.index_session(&meta_for_index, &entries_for_index) {
1376                    tracing::warn!(
1377                        session_id = %id,
1378                        error = %err,
1379                        "failed to update search index after save_entries"
1380                    );
1381                }
1382                Ok(())
1383            });
1384        }
1385
1386        Ok(())
1387    }
1388
1389    /// Append entries to an existing session without rewriting the whole file.
1390    ///
1391    /// This is the append-only counterpart to [`Self::save_entries`]. Instead of
1392    /// rewriting every record on each turn, it appends only the new `entries`
1393    /// and patches the metadata line in place (reusing the same on-disk
1394    /// machinery and locking guarantees as the internal append path).
1395    ///
1396    /// The caller's `meta.sequence` must match the on-disk sequence
1397    /// (optimistic concurrency, identical to [`Self::save_entries`]); a mismatch
1398    /// returns an error. Every other `meta` field is written as provided —
1399    /// only `id` and `sequence` are set by the store. The returned
1400    /// [`SessionMeta`] carries the incremented sequence so callers stay in
1401    /// sync without re-reading the file.
1402    ///
1403    /// If the metadata line can no longer be patched in place — its serialized
1404    /// form would outgrow the slot reserved on disk — this transparently falls
1405    /// back to a full rewrite that preserves all existing lines.
1406    ///
1407    /// Errors if the session file does not exist; use [`Self::save_entries`] to
1408    /// create a new session.
1409    ///
1410    /// When the `search` feature is enabled, only the newly appended
1411    /// `entries` are added to the tantivy index afterwards — the session's
1412    /// existing documents are untouched (best-effort — index errors are
1413    /// logged but do not fail the append).
1414    pub fn append_entries(
1415        &self,
1416        id: &str,
1417        meta: &SessionMeta,
1418        entries: &[SessionEntry],
1419    ) -> io::Result<SessionMeta> {
1420        validate_session_id(id)?;
1421        let path = session_path(&self.sessions_dir, id);
1422
1423        let write_meta = with_target_lock(&path, || {
1424            check_sequence_path(&path, id, meta.sequence)?;
1425            let (_, meta_line_len) = read_meta_with_line_len(&path, id)?;
1426
1427            let mut write_meta = meta.clone();
1428            write_meta.id = id.to_string();
1429            write_meta.updated_at = now_utc();
1430            write_meta.sequence += 1;
1431
1432            let record_lines = entries
1433                .iter()
1434                .map(|entry| serde_json::to_string(entry).map_err(io::Error::other))
1435                .collect::<io::Result<Vec<_>>>()?;
1436
1437            if !append_records_in_place(&path, &write_meta, meta_line_len, &record_lines)? {
1438                // The patched metadata line would outgrow its on-disk slot —
1439                // fall back to a full rewrite that keeps every existing line.
1440                let (_, mut existing_lines) = read_meta_and_message_lines(&path, id)?;
1441                existing_lines.extend(record_lines);
1442                rewrite_session_file_locked(&path, &write_meta, &existing_lines)?;
1443            }
1444
1445            Ok(write_meta)
1446        })?;
1447
1448        #[cfg(feature = "search")]
1449        self.append_to_active_search_index(id, &write_meta, entries);
1450
1451        Ok(write_meta)
1452    }
1453
1454    /// Load a session with rich entry types.
1455    ///
1456    /// Parses each line after metadata as a [`SessionEntry`]. Old-format lines
1457    /// (raw `LlmMessage` without `entry_type`) are interpreted as
1458    /// [`SessionEntry::Message`] for backward compatibility.
1459    pub fn load_entries(&self, id: &str) -> io::Result<(SessionMeta, Vec<SessionEntry>)> {
1460        validate_session_id(id)?;
1461
1462        let path = session_path(&self.sessions_dir, id);
1463        with_target_lock(&path, || {
1464            let (meta, lines) = read_meta_and_message_lines(&path, id)?;
1465            let (meta, classified) = self.classify_and_migrate(meta, lines, id)?;
1466
1467            let entries = classified
1468                .into_iter()
1469                .filter_map(|item| match item {
1470                    ClassifiedLine::Entry(entry) => Some(*entry),
1471                    ClassifiedLine::Custom(_) | ClassifiedLine::State => None,
1472                })
1473                .collect();
1474
1475            Ok((meta, entries))
1476        })
1477    }
1478
1479    /// Parse every message line, classify each as a migrateable
1480    /// [`SessionEntry`] or a pass-through custom/state wrapper, then run any
1481    /// configured migrators over the `SessionEntry` subset.
1482    ///
1483    /// This is the single migration entry point shared by `load()` and
1484    /// `load_entries()` so both observe identical post-migration state.
1485    /// Positional ordering of pass-through wrappers relative to each other
1486    /// (and relative to the migrateable block as a whole) is preserved;
1487    /// migrators may freely insert/remove entries within the entry block.
1488    fn classify_and_migrate(
1489        &self,
1490        mut meta: SessionMeta,
1491        lines: Vec<String>,
1492        id: &str,
1493    ) -> io::Result<(SessionMeta, Vec<ClassifiedLine>)> {
1494        let mut classified: Vec<ClassifiedLine> = Vec::with_capacity(lines.len());
1495        for (idx, line) in lines.into_iter().enumerate() {
1496            if line.trim().is_empty() {
1497                continue;
1498            }
1499            let line_num = idx + 2;
1500            match classify_line(&line) {
1501                Ok(item) => classified.push(item),
1502                Err(error) => {
1503                    tracing::warn!(
1504                        line = line_num,
1505                        error = %error,
1506                        "skipping unparseable line in session {id}"
1507                    );
1508                }
1509            }
1510        }
1511
1512        // Split out the migrateable subset, run the migrator pipeline, then
1513        // weave the migrated entries back into the classified list.
1514        let original_entry_count = classified
1515            .iter()
1516            .filter(|item| matches!(item, ClassifiedLine::Entry(_)))
1517            .count();
1518        let mut entries: Vec<SessionEntry> = classified
1519            .iter()
1520            .filter_map(|item| match item {
1521                ClassifiedLine::Entry(entry) => Some((**entry).clone()),
1522                _ => None,
1523            })
1524            .collect();
1525
1526        crate::migrate::run_migrations(&mut meta, &mut entries, &self.migrators)?;
1527
1528        let rebuilt = weave_migrated_entries(classified, entries, original_entry_count);
1529        Ok((meta, rebuilt))
1530    }
1531}
1532
1533/// Weave a migrated entry list back into a classified line sequence.
1534///
1535/// If the migrator preserved the entry count (`migrated.len() ==
1536/// original_entry_count`), each entry slot is replaced 1:1 in position,
1537/// preserving the original interleaving with custom/state wrappers (the
1538/// common case; matches pre-migration-aware `load()` behavior exactly).
1539///
1540/// If the migrator added or dropped entries, positional correspondence to
1541/// individual slots is no longer defined — we collapse all migrated entries
1542/// into the first entry slot (or append them if no entry slots existed)
1543/// and keep pass-through wrappers in their original positions.
1544fn weave_migrated_entries(
1545    classified: Vec<ClassifiedLine>,
1546    migrated: Vec<SessionEntry>,
1547    original_entry_count: usize,
1548) -> Vec<ClassifiedLine> {
1549    let mut rebuilt: Vec<ClassifiedLine> = Vec::with_capacity(classified.len().max(migrated.len()));
1550    if migrated.len() == original_entry_count {
1551        let mut iter = migrated.into_iter();
1552        for item in classified {
1553            match item {
1554                ClassifiedLine::Entry(_) => {
1555                    if let Some(next) = iter.next() {
1556                        rebuilt.push(ClassifiedLine::Entry(Box::new(next)));
1557                    }
1558                }
1559                passthrough @ (ClassifiedLine::Custom(_) | ClassifiedLine::State) => {
1560                    rebuilt.push(passthrough);
1561                }
1562            }
1563        }
1564    } else {
1565        let mut drained = false;
1566        let mut migrated_iter = migrated.into_iter();
1567        for item in classified {
1568            match item {
1569                ClassifiedLine::Entry(_) => {
1570                    if !drained {
1571                        for e in migrated_iter.by_ref() {
1572                            rebuilt.push(ClassifiedLine::Entry(Box::new(e)));
1573                        }
1574                        drained = true;
1575                    }
1576                }
1577                passthrough @ (ClassifiedLine::Custom(_) | ClassifiedLine::State) => {
1578                    rebuilt.push(passthrough);
1579                }
1580            }
1581        }
1582        for e in migrated_iter {
1583            rebuilt.push(ClassifiedLine::Entry(Box::new(e)));
1584        }
1585    }
1586    rebuilt
1587}
1588
1589/// A JSONL line after parsing, classified so the migration pipeline can treat
1590/// migrateable entries distinctly from opaque pass-through wrappers.
1591///
1592/// The `Entry` variant is boxed because [`SessionEntry`] is comparatively
1593/// large (embeds a full `LlmMessage`) and dwarfs the other variants — the
1594/// `Box` keeps the enum small enough to avoid `clippy::large_enum_variant`.
1595#[derive(Debug, Clone)]
1596enum ClassifiedLine {
1597    /// A [`SessionEntry`] — either a tagged entry or a legacy raw
1598    /// [`LlmMessage`] rehydrated as [`SessionEntry::Message`]. Migrators run
1599    /// against these.
1600    Entry(Box<SessionEntry>),
1601    /// A `_custom: true` envelope — preserved verbatim so
1602    /// [`SessionStore::load`] can restore it via the caller's registry.
1603    Custom(serde_json::Value),
1604    /// A `_state: true` record. `load_state()` reads state directly from the
1605    /// file; `load()` and `load_entries()` both skip these, so the payload
1606    /// is intentionally discarded here.
1607    State,
1608}
1609
1610/// Classify a single non-empty JSONL message line.
1611///
1612/// Order of checks mirrors the persisted format precedence: state/custom
1613/// wrappers win over the tagged [`SessionEntry`] path, which in turn covers
1614/// the legacy raw [`LlmMessage`] format.
1615fn classify_line(line: &str) -> io::Result<ClassifiedLine> {
1616    let value: serde_json::Value = serde_json::from_str(line).map_err(io::Error::other)?;
1617
1618    if value.get("_state").and_then(serde_json::Value::as_bool) == Some(true) {
1619        return Ok(ClassifiedLine::State);
1620    }
1621    if value.get("_custom").and_then(serde_json::Value::as_bool) == Some(true) {
1622        return Ok(ClassifiedLine::Custom(value));
1623    }
1624
1625    // Neither state nor custom wrapper: either a tagged SessionEntry or a
1626    // legacy raw LlmMessage. SessionEntry::parse handles both.
1627    SessionEntry::parse(line)
1628        .map(|entry| ClassifiedLine::Entry(Box::new(entry)))
1629        .map_err(io::Error::other)
1630}
1631
1632/// Convert a `_custom` envelope (as stored on disk) back into an
1633/// [`AgentMessage::Custom`] using the supplied registry.
1634///
1635/// Returns `Ok(None)` when the caller supplied no registry (custom messages
1636/// are skipped — matching prior `load()` behavior).
1637fn custom_envelope_to_message(
1638    envelope: &serde_json::Value,
1639    registry: Option<&CustomMessageRegistry>,
1640) -> io::Result<Option<AgentMessage>> {
1641    let line = serde_json::to_string(envelope).map_err(io::Error::other)?;
1642    crate::codec::decode_jsonl_message_line(&line, registry)
1643}
1644
1645fn classified_lines_to_messages(
1646    classified: Vec<ClassifiedLine>,
1647    registry: Option<&CustomMessageRegistry>,
1648    id: &str,
1649) -> Vec<AgentMessage> {
1650    let mut messages = Vec::new();
1651    for item in classified {
1652        match item {
1653            ClassifiedLine::Entry(entry) => {
1654                if let SessionEntry::Message(llm_msg) = *entry {
1655                    messages.push(AgentMessage::Llm(llm_msg));
1656                }
1657            }
1658            ClassifiedLine::Custom(envelope) => {
1659                match custom_envelope_to_message(&envelope, registry) {
1660                    Ok(Some(msg)) => messages.push(msg),
1661                    Ok(None) => {}
1662                    Err(error) => {
1663                        tracing::warn!(
1664                            error = %error,
1665                            "skipping unrestorable custom message in session {id}"
1666                        );
1667                    }
1668                }
1669            }
1670            ClassifiedLine::State => {}
1671        }
1672    }
1673
1674    messages
1675}
1676
1677#[cfg(test)]
1678#[path = "jsonl_tests.rs"]
1679mod tests;