1use std::fs::Metadata;
7use std::path::{Path, PathBuf};
8use std::time::UNIX_EPOCH;
9
10use serde_json::{json, Value};
11
12use crate::catalog::{SessionLocator, StorageLocator};
13use crate::native_store::load_native_store_family;
14use crate::session::{looks_like_sqlite, Session, SessionSource};
15use crate::{ChatMessage, Error, Fidelity, Result};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SessionSnapshotReason {
20 Initial,
22 HistoryRewritten,
24 SourceChanged,
26}
27
28impl SessionSnapshotReason {
29 fn as_str(self) -> &'static str {
30 match self {
31 Self::Initial => "initial",
32 Self::HistoryRewritten => "history_rewritten",
33 Self::SourceChanged => "source_changed",
34 }
35 }
36}
37
38#[derive(Debug, Clone)]
40pub enum SessionWatchEvent {
41 SessionSnapshot {
43 sequence: u64,
45 reason: SessionSnapshotReason,
47 session: Box<Session>,
49 },
50 MessagesAppended {
52 sequence: u64,
54 session_id: Option<String>,
56 messages: Vec<ChatMessage>,
58 total_message_count: usize,
60 },
61 WatchError {
63 sequence: u64,
65 message: String,
67 },
68}
69
70impl SessionWatchEvent {
71 pub fn sequence(&self) -> u64 {
73 match self {
74 Self::SessionSnapshot { sequence, .. }
75 | Self::MessagesAppended { sequence, .. }
76 | Self::WatchError { sequence, .. } => *sequence,
77 }
78 }
79
80 pub fn to_json(&self) -> Value {
82 match self {
83 Self::SessionSnapshot {
84 sequence,
85 reason,
86 session,
87 } => json!({
88 "type": "session_snapshot",
89 "sequence": sequence,
90 "reason": reason.as_str(),
91 "session": normalized_session_json(session),
92 }),
93 Self::MessagesAppended {
94 sequence,
95 session_id,
96 messages,
97 total_message_count,
98 } => json!({
99 "type": "messages_appended",
100 "sequence": sequence,
101 "session_id": session_id,
102 "messages": messages.iter().map(message_json).collect::<Vec<_>>(),
103 "total_message_count": total_message_count,
104 }),
105 Self::WatchError { sequence, message } => json!({
106 "type": "watch_error",
107 "sequence": sequence,
108 "recoverable": true,
109 "message": message,
110 }),
111 }
112 }
113}
114
115pub struct SessionFollower {
122 path: PathBuf,
123 opencode_session: Option<String>,
124 goose_sqlite: bool,
125 fidelity: Fidelity,
126 include_subagents: bool,
127 message_limit: Option<usize>,
128 max_message_chars: Option<usize>,
129 display_history: bool,
130 current: Session,
131 fingerprint: Vec<PathStamp>,
132 initial_pending: bool,
133 next_sequence: u64,
134}
135
136#[derive(Clone, Copy)]
137struct FollowerView {
138 include_subagents: bool,
139 message_limit: Option<usize>,
140 max_message_chars: Option<usize>,
141 display_history: bool,
142}
143
144impl SessionFollower {
145 pub fn open_locator(locator: &SessionLocator) -> Result<Self> {
147 Self::open_locator_with_fidelity(locator, Fidelity::ByteLossless)
148 }
149
150 pub fn open_locator_with_fidelity(
156 locator: &SessionLocator,
157 fidelity: Fidelity,
158 ) -> Result<Self> {
159 Self::open_locator_with_view(locator, fidelity, true, None, None, false)
160 }
161
162 pub fn open_locator_with_view(
165 locator: &SessionLocator,
166 fidelity: Fidelity,
167 include_subagents: bool,
168 message_limit: Option<usize>,
169 max_message_chars: Option<usize>,
170 display_history: bool,
171 ) -> Result<Self> {
172 match &locator.storage {
173 StorageLocator::File { path } => Self::open_with_options(
174 path,
175 None,
176 false,
177 fidelity,
178 FollowerView {
179 include_subagents,
180 message_limit,
181 max_message_chars,
182 display_history,
183 },
184 ),
185 StorageLocator::Sqlite { path, selector } => Self::open_with_options(
186 path,
187 Some(selector),
188 locator.harness.as_str() == crate::HarnessId::GOOSE,
189 fidelity,
190 FollowerView {
191 include_subagents,
192 message_limit,
193 max_message_chars,
194 display_history,
195 },
196 ),
197 }
198 }
199
200 pub fn open(path: impl Into<PathBuf>, opencode_session: Option<&str>) -> Result<Self> {
206 Self::open_with_fidelity(path, opencode_session, Fidelity::ByteLossless)
207 }
208
209 pub fn open_with_fidelity(
211 path: impl Into<PathBuf>,
212 opencode_session: Option<&str>,
213 fidelity: Fidelity,
214 ) -> Result<Self> {
215 Self::open_with_options(
216 path,
217 opencode_session,
218 false,
219 fidelity,
220 FollowerView {
221 include_subagents: true,
222 message_limit: None,
223 max_message_chars: None,
224 display_history: false,
225 },
226 )
227 }
228
229 fn open_with_options(
230 path: impl Into<PathBuf>,
231 opencode_session: Option<&str>,
232 goose_sqlite: bool,
233 fidelity: Fidelity,
234 view: FollowerView,
235 ) -> Result<Self> {
236 let path = path.into();
237 let sqlite = looks_like_sqlite(&path);
238 if opencode_session.is_some() && !sqlite {
239 return Err(Error::Other(format!(
240 "an OpenCode session selector requires a SQLite store; {} is not one",
241 path.display()
242 )));
243 }
244
245 let mut selected = opencode_session.map(str::to_owned);
246 let mut current = load_selected(&path, selected.as_deref(), goose_sqlite, fidelity, view)?;
247 bound_session_view(&mut current, view.message_limit, view.max_message_chars);
248 if sqlite && selected.is_none() {
249 selected = current.meta.session_id.clone();
250 }
251 let fingerprint =
252 source_fingerprint(&path, ¤t, selected.as_deref(), view.include_subagents)?;
253
254 Ok(Self {
255 path,
256 opencode_session: selected,
257 goose_sqlite,
258 fidelity,
259 include_subagents: view.include_subagents,
260 message_limit: view.message_limit,
261 max_message_chars: view.max_message_chars,
262 display_history: view.display_history,
263 current,
264 fingerprint,
265 initial_pending: true,
266 next_sequence: 1,
267 })
268 }
269
270 pub fn poll(&mut self) -> Result<Option<SessionWatchEvent>> {
275 if self.initial_pending {
276 self.initial_pending = false;
277 return Ok(Some(self.snapshot(SessionSnapshotReason::Initial)));
278 }
279
280 let observed = source_fingerprint(
281 &self.path,
282 &self.current,
283 self.opencode_session.as_deref(),
284 self.include_subagents,
285 )?;
286 if observed == self.fingerprint {
287 return Ok(None);
288 }
289
290 let loaded = load_selected(
291 &self.path,
292 self.opencode_session.as_deref(),
293 self.goose_sqlite,
294 self.fidelity,
295 FollowerView {
296 include_subagents: self.include_subagents,
297 message_limit: self.message_limit,
298 max_message_chars: self.max_message_chars,
299 display_history: self.display_history,
300 },
301 );
302 self.fingerprint = observed;
303 let next = match loaded {
304 Ok(session) if session.parse_error_lines > 0 => {
305 let count = session.parse_error_lines;
306 Some(self.watch_error(format!(
307 "{} contains {count} malformed or truncated JSON line(s); retaining the last good snapshot",
308 self.path.display()
309 )))
310 }
311 Err(error) => Some(self.watch_error(format!(
312 "could not reload {}: {error}; retaining the last good snapshot",
313 self.path.display()
314 ))),
315 Ok(mut session) => {
316 bound_session_view(&mut session, self.message_limit, self.max_message_chars);
317 self.event_for_session(session)
318 }
319 };
320 Ok(next)
321 }
322
323 fn event_for_session(&mut self, session: Session) -> Option<SessionWatchEvent> {
324 if normalized_session_eq(&self.current, &session) {
325 self.current = session;
326 return None;
327 }
328
329 let identity_same = session_identity_eq(&self.current, &session);
330 let subagents_same = normalized_subagents_eq(&self.current, &session);
331 let append_prefix = if identity_same && subagents_same {
332 append_prefix_len(&self.current.messages, &session.messages)
333 } else {
334 0
335 };
336 if append_prefix > 0 && session.messages.len() > append_prefix {
337 let messages = session.messages[append_prefix..].to_vec();
338 let session_id = session.meta.session_id.clone();
339 let total_message_count = session
340 .imported_message_count
341 .unwrap_or(session.messages.len())
342 .max(session.messages.len());
343 self.current = session;
344 return Some(SessionWatchEvent::MessagesAppended {
345 sequence: self.take_sequence(),
346 session_id,
347 messages,
348 total_message_count,
349 });
350 }
351
352 let reason = if identity_same {
353 SessionSnapshotReason::HistoryRewritten
354 } else {
355 SessionSnapshotReason::SourceChanged
356 };
357 self.current = session;
358 Some(self.snapshot(reason))
359 }
360
361 fn snapshot(&mut self, reason: SessionSnapshotReason) -> SessionWatchEvent {
362 SessionWatchEvent::SessionSnapshot {
363 sequence: self.take_sequence(),
364 reason,
365 session: Box::new(self.current.clone()),
366 }
367 }
368
369 fn watch_error(&mut self, message: String) -> SessionWatchEvent {
370 SessionWatchEvent::WatchError {
371 sequence: self.take_sequence(),
372 message,
373 }
374 }
375
376 fn take_sequence(&mut self) -> u64 {
377 let sequence = self.next_sequence;
378 self.next_sequence += 1;
379 sequence
380 }
381}
382
383fn load_selected(
384 path: &Path,
385 selected: Option<&str>,
386 goose_sqlite: bool,
387 fidelity: Fidelity,
388 view: FollowerView,
389) -> Result<Session> {
390 let sqlite = looks_like_sqlite(path);
391 if sqlite {
392 if goose_sqlite {
393 let selector = selected.ok_or_else(|| {
394 Error::Other("a Goose SQLite locator requires a session selector".to_string())
395 })?;
396 Ok(Session::from_goose_sqlite(path, selector)?)
397 } else {
398 Ok(Session::from_opencode_sqlite(path, selected)?)
399 }
400 } else if let Some(session) = load_native_store_family(path)? {
401 Ok(session)
402 } else if view.display_history {
403 Ok(Session::load_display_view(
404 path,
405 fidelity,
406 view.message_limit.unwrap_or(500),
407 )?)
408 } else if view.include_subagents {
409 Ok(Session::load_with_fidelity(path, fidelity)?)
410 } else {
411 Ok(Session::load_parent_with_fidelity(path, fidelity)?)
412 }
413}
414
415#[doc(hidden)]
416pub fn bound_session_view(
417 session: &mut Session,
418 message_limit: Option<usize>,
419 max_message_chars: Option<usize>,
420) {
421 if let Some(limit) = message_limit {
422 if session.messages.len() > limit {
423 session.messages.drain(..session.messages.len() - limit);
424 }
425 }
426
427 let Some(max_chars) = max_message_chars else {
428 return;
429 };
430 for message in &mut session.messages {
431 if let Some(content) = &mut message.content {
432 truncate_utf8(content, max_chars);
433 }
434 if let Some(parts) = &mut message.content_parts {
435 for part in parts {
436 truncate_value_strings(part, max_chars);
437 }
438 }
439 if let Some(tool_calls) = &mut message.tool_calls {
440 for call in tool_calls {
441 truncate_utf8(&mut call.function.arguments, max_chars);
442 }
443 }
444 for value in message.metadata.values_mut() {
445 truncate_utf8(value, max_chars);
446 }
447 }
448}
449
450fn truncate_value_strings(value: &mut Value, max_chars: usize) {
451 match value {
452 Value::String(text) => truncate_utf8(text, max_chars),
453 Value::Array(values) => {
454 for value in values {
455 truncate_value_strings(value, max_chars);
456 }
457 }
458 Value::Object(values) => {
459 for value in values.values_mut() {
460 truncate_value_strings(value, max_chars);
461 }
462 }
463 _ => {}
464 }
465}
466
467fn truncate_utf8(value: &mut String, max_chars: usize) {
468 let Some((byte_index, _)) = value.char_indices().nth(max_chars) else {
469 return;
470 };
471 value.truncate(byte_index);
472 value.push_str("\n…");
473}
474
475fn session_identity_eq(left: &Session, right: &Session) -> bool {
476 left.meta.source == right.meta.source
477 && left.meta.session_id == right.meta.session_id
478 && left.meta.model == right.meta.model
479 && left.meta.cwd == right.meta.cwd
480 && left.meta.system_prompt == right.meta.system_prompt
481 && left.meta.agent_id == right.meta.agent_id
482 && left.meta.parent_tool_use_id == right.meta.parent_tool_use_id
483 && left.meta.lineage == right.meta.lineage
484}
485
486fn normalized_session_eq(left: &Session, right: &Session) -> bool {
487 session_identity_eq(left, right)
488 && left.messages == right.messages
489 && normalized_subagents_eq(left, right)
490 && left.parse_error_lines == right.parse_error_lines
491 && left.load_residue == right.load_residue
492}
493
494fn normalized_subagents_eq(left: &Session, right: &Session) -> bool {
495 left.subagents.len() == right.subagents.len()
496 && left
497 .subagents
498 .iter()
499 .zip(&right.subagents)
500 .all(|(left, right)| normalized_session_eq(left, right))
501}
502
503fn append_prefix_len(current: &[ChatMessage], next: &[ChatMessage]) -> usize {
508 let plain = (1..=current.len().min(next.len()))
509 .rev()
510 .find(|&length| current[current.len() - length..] == next[..length])
511 .unwrap_or(0);
512 let anchored = if current.first() == next.first() && next.len() > 1 {
513 (1..=current.len().saturating_sub(1).min(next.len() - 1))
514 .rev()
515 .find(|&length| current[current.len() - length..] == next[1..1 + length])
516 .map(|length| length + 1)
517 .unwrap_or(0)
518 } else {
519 0
520 };
521 plain.max(anchored)
522}
523
524fn source_name(source: SessionSource) -> &'static str {
525 match source {
526 SessionSource::ClaudeCode => "claude_code",
527 SessionSource::Codex => "codex",
528 SessionSource::OpenCode => "opencode",
529 SessionSource::Pi => "pi",
530 SessionSource::Grok => "grok",
531 SessionSource::Gemini => "gemini",
532 SessionSource::Goose => "goose",
533 SessionSource::Native => "native",
534 }
535}
536
537#[doc(hidden)]
538pub fn message_json(message: &ChatMessage) -> Value {
539 let mut value = serde_json::to_value(message).unwrap_or_else(|_| json!({}));
540 if let Value::Object(object) = &mut value {
541 object.insert("metadata".to_string(), json!(message.metadata));
542 }
543 value
544}
545
546pub fn normalized_session_json(session: &Session) -> Value {
548 json!({
549 "source": source_name(session.meta.source),
550 "session_id": session.meta.session_id,
551 "model": session.meta.model,
552 "cwd": session.meta.cwd,
553 "system_prompt": session.meta.system_prompt,
554 "agent_id": session.meta.agent_id,
555 "parent_tool_use_id": session.meta.parent_tool_use_id,
556 "lineage": session.meta.lineage,
557 "messages": session.messages.iter().map(message_json).collect::<Vec<_>>(),
558 "subagents": session.subagents.iter().map(normalized_session_json).collect::<Vec<_>>(),
559 "raw_record_count": session.raw.len(),
560 "total_message_count": session.imported_message_count.unwrap_or(session.messages.len()).max(session.messages.len()),
561 "parse_error_lines": session.parse_error_lines,
562 "fidelity": session.load_fidelity(),
567 "residue": session.load_residue,
568 })
569}
570
571#[derive(Debug, Clone, PartialEq, Eq)]
572struct PathStamp {
573 path: PathBuf,
574 kind: StampKind,
575 len: u64,
576 modified_nanos: Option<u128>,
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq)]
580enum StampKind {
581 Missing,
582 File,
583 Directory,
584 Other,
585}
586
587fn source_fingerprint(
588 path: &Path,
589 session: &Session,
590 selected_session: Option<&str>,
591 include_subagents: bool,
592) -> Result<Vec<PathStamp>> {
593 let mut stamps = vec![path_stamp(path)?];
594 match session.meta.source {
595 SessionSource::ClaudeCode if include_subagents => {
596 if let (Some(parent), Some(stem)) = (path.parent(), path.file_stem()) {
597 collect_tree_stamps(&parent.join(stem).join("subagents"), &mut stamps)?;
598 }
599 }
600 SessionSource::OpenCode if looks_like_sqlite(path) => {
601 stamps.push(path_stamp(&path_with_suffix(path, "-wal"))?);
602 stamps.push(path_stamp(&path_with_suffix(path, "-shm"))?);
603 if let (Some(parent), Some(session_id)) = (path.parent(), selected_session) {
604 stamps.push(path_stamp(
605 &parent
606 .join("storage")
607 .join("session_diff")
608 .join(format!("{session_id}.json")),
609 )?);
610 }
611 }
612 SessionSource::Grok => {
613 if let Some(parent) = path.parent() {
614 stamps.push(path_stamp(&parent.join("updates.jsonl"))?);
619 stamps.push(path_stamp(&parent.join("summary.json"))?);
620 }
621 }
622 SessionSource::Native => {
623 if let (Some(parent), Some(stem)) = (path.parent(), path.file_stem()) {
624 stamps.push(path_stamp(
625 &parent.join(format!("{}.sidecar.jsonl", stem.to_string_lossy())),
626 )?);
627 stamps.push(path_stamp(
628 &parent.join(format!("{}.meta.json", stem.to_string_lossy())),
629 )?);
630 collect_tree_stamps(
631 &parent.join(format!("{}.subagents", stem.to_string_lossy())),
632 &mut stamps,
633 )?;
634 }
635 }
636 _ => {}
637 }
638 stamps.sort_by(|left, right| left.path.cmp(&right.path));
639 Ok(stamps)
640}
641
642fn collect_tree_stamps(path: &Path, out: &mut Vec<PathStamp>) -> Result<()> {
643 collect_tree_stamps_inner(path, out, true)
644}
645
646fn collect_tree_stamps_inner(path: &Path, out: &mut Vec<PathStamp>, follow: bool) -> Result<()> {
647 let stamp = if follow {
648 path_stamp(path)?
649 } else {
650 path_stamp_no_follow(path)?
651 };
652 let is_directory = stamp.kind == StampKind::Directory;
653 out.push(stamp);
654 if !is_directory {
655 return Ok(());
656 }
657
658 let mut children = std::fs::read_dir(path)?.collect::<std::io::Result<Vec<_>>>()?;
659 children.sort_by_key(|entry| entry.path());
660 for child in children {
661 collect_tree_stamps_inner(&child.path(), out, false)?;
662 }
663 Ok(())
664}
665
666fn path_stamp(path: &Path) -> Result<PathStamp> {
667 path_stamp_with(path, |path| std::fs::metadata(path))
668}
669
670fn path_stamp_no_follow(path: &Path) -> Result<PathStamp> {
671 path_stamp_with(path, |path| std::fs::symlink_metadata(path))
672}
673
674fn path_stamp_with(
675 path: &Path,
676 metadata: impl FnOnce(&Path) -> std::io::Result<Metadata>,
677) -> Result<PathStamp> {
678 match metadata(path) {
679 Ok(metadata) => Ok(stamp_from_metadata(path, &metadata)),
680 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(PathStamp {
681 path: path.to_path_buf(),
682 kind: StampKind::Missing,
683 len: 0,
684 modified_nanos: None,
685 }),
686 Err(error) => Err(error.into()),
687 }
688}
689
690fn path_with_suffix(path: &Path, suffix: &str) -> PathBuf {
691 let mut value = path.as_os_str().to_os_string();
692 value.push(suffix);
693 PathBuf::from(value)
694}
695
696fn stamp_from_metadata(path: &Path, metadata: &Metadata) -> PathStamp {
697 let file_type = metadata.file_type();
698 let kind = if file_type.is_file() {
699 StampKind::File
700 } else if file_type.is_dir() {
701 StampKind::Directory
702 } else {
703 StampKind::Other
704 };
705 PathStamp {
706 path: path.to_path_buf(),
707 kind,
708 len: metadata.len(),
709 modified_nanos: metadata
710 .modified()
711 .ok()
712 .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
713 .map(|duration| duration.as_nanos()),
714 }
715}
716
717#[cfg(test)]
718mod tests {
719 use super::append_prefix_len;
720 use crate::ChatMessage;
721
722 #[test]
723 fn bounded_append_overlap_handles_plain_and_user_anchored_windows() {
724 let user = ChatMessage::user("anchor");
725 let one = ChatMessage::assistant("one");
726 let two = ChatMessage::assistant("two");
727 let three = ChatMessage::assistant("three");
728 let newest = ChatMessage::user("newest");
729
730 assert_eq!(
731 append_prefix_len(
732 &[one.clone(), two.clone(), three.clone()],
733 &[two.clone(), three.clone(), newest.clone()],
734 ),
735 2,
736 );
737 assert_eq!(
738 append_prefix_len(
739 &[user.clone(), one, two.clone(), three.clone()],
740 &[user, two, three, newest],
741 ),
742 3,
743 );
744 }
745}