1use 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 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
236fn 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
266fn 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
297const META_TAIL_WINDOW: u64 = 64 * 1024;
299
300fn 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 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 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 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
367fn last_meta_in_complete_lines(bytes: &[u8]) -> Option<SessionMeta> {
371 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
719fn 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
742pub struct JsonlSessionStore {
755 sessions_dir: PathBuf,
756 migrators: Vec<Box<dyn crate::migrate::SessionMigrator>>,
757 #[cfg(feature = "search")]
764 tantivy_index: std::sync::Mutex<Option<crate::search::index::TantivyIndex>>,
765 #[cfg(feature = "search")]
768 index_built: std::sync::Mutex<bool>,
769}
770
771impl JsonlSessionStore {
772 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 #[cfg(feature = "search")]
795 pub fn open_search_index(&self) -> io::Result<()> {
796 self.with_tantivy_index(|_| Ok(()))
797 }
798
799 #[cfg(feature = "search")]
806 pub fn rebuild_search_index(&self) -> io::Result<()> {
807 self.with_tantivy_index(|index| {
808 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 #[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 #[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 #[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 #[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 pub fn default_dir() -> Option<PathBuf> {
923 dirs::config_dir().map(|d| d.join("swink-agent").join("sessions"))
924 }
925
926 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 #[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 _ => 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 if int_path.exists() {
1097 std::fs::remove_file(int_path)?;
1098 }
1099 Ok(())
1100 })?;
1101
1102 #[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 if let Some(ref types) = options.entry_types {
1200 entries.retain(|entry| types.iter().any(|t| t == entry.entry_type_name()));
1201 }
1202
1203 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 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 #[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 #[cfg(not(feature = "search"))]
1231 linear_search(self, query, options)
1232 }
1233}
1234
1235#[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 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 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 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 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 serde_json::to_writer(&mut *writer, &write_meta).map_err(io::Error::other)?;
1352 writeln!(writer)?;
1353
1354 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 #[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 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 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 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 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 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
1533fn 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#[derive(Debug, Clone)]
1596enum ClassifiedLine {
1597 Entry(Box<SessionEntry>),
1601 Custom(serde_json::Value),
1604 State,
1608}
1609
1610fn 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 SessionEntry::parse(line)
1628 .map(|entry| ClassifiedLine::Entry(Box::new(entry)))
1629 .map_err(io::Error::other)
1630}
1631
1632fn 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;