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