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::session::{looks_like_sqlite, Session, SessionSource};
14use crate::{ChatMessage, Error, Fidelity, Result};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SessionSnapshotReason {
19 Initial,
21 HistoryRewritten,
23 SourceChanged,
25}
26
27impl SessionSnapshotReason {
28 fn as_str(self) -> &'static str {
29 match self {
30 Self::Initial => "initial",
31 Self::HistoryRewritten => "history_rewritten",
32 Self::SourceChanged => "source_changed",
33 }
34 }
35}
36
37#[derive(Debug, Clone)]
39pub enum SessionWatchEvent {
40 SessionSnapshot {
42 sequence: u64,
44 reason: SessionSnapshotReason,
46 session: Box<Session>,
48 },
49 MessagesAppended {
51 sequence: u64,
53 session_id: Option<String>,
55 messages: Vec<ChatMessage>,
57 },
58 WatchError {
60 sequence: u64,
62 message: String,
64 },
65}
66
67impl SessionWatchEvent {
68 pub fn sequence(&self) -> u64 {
70 match self {
71 Self::SessionSnapshot { sequence, .. }
72 | Self::MessagesAppended { sequence, .. }
73 | Self::WatchError { sequence, .. } => *sequence,
74 }
75 }
76
77 pub fn to_json(&self) -> Value {
79 match self {
80 Self::SessionSnapshot {
81 sequence,
82 reason,
83 session,
84 } => json!({
85 "type": "session_snapshot",
86 "sequence": sequence,
87 "reason": reason.as_str(),
88 "session": normalized_session_json(session),
89 }),
90 Self::MessagesAppended {
91 sequence,
92 session_id,
93 messages,
94 } => json!({
95 "type": "messages_appended",
96 "sequence": sequence,
97 "session_id": session_id,
98 "messages": messages.iter().map(message_json).collect::<Vec<_>>(),
99 }),
100 Self::WatchError { sequence, message } => json!({
101 "type": "watch_error",
102 "sequence": sequence,
103 "recoverable": true,
104 "message": message,
105 }),
106 }
107 }
108}
109
110pub struct SessionFollower {
117 path: PathBuf,
118 opencode_session: Option<String>,
119 fidelity: Fidelity,
120 current: Session,
121 fingerprint: Vec<PathStamp>,
122 initial_pending: bool,
123 next_sequence: u64,
124}
125
126impl SessionFollower {
127 pub fn open_locator(locator: &SessionLocator) -> Result<Self> {
129 Self::open_locator_with_fidelity(locator, Fidelity::ByteLossless)
130 }
131
132 pub fn open_locator_with_fidelity(
138 locator: &SessionLocator,
139 fidelity: Fidelity,
140 ) -> Result<Self> {
141 match &locator.storage {
142 StorageLocator::File { path } => Self::open_with_fidelity(path, None, fidelity),
143 StorageLocator::Sqlite { path, selector } => {
144 Self::open_with_fidelity(path, Some(selector), fidelity)
145 }
146 }
147 }
148
149 pub fn open(path: impl Into<PathBuf>, opencode_session: Option<&str>) -> Result<Self> {
155 Self::open_with_fidelity(path, opencode_session, Fidelity::ByteLossless)
156 }
157
158 pub fn open_with_fidelity(
160 path: impl Into<PathBuf>,
161 opencode_session: Option<&str>,
162 fidelity: Fidelity,
163 ) -> Result<Self> {
164 let path = path.into();
165 let sqlite = looks_like_sqlite(&path);
166 if opencode_session.is_some() && !sqlite {
167 return Err(Error::Other(format!(
168 "an OpenCode session selector requires a SQLite store; {} is not one",
169 path.display()
170 )));
171 }
172
173 let mut selected = opencode_session.map(str::to_owned);
174 let current = load_selected(&path, selected.as_deref(), sqlite, fidelity)?;
175 if sqlite && selected.is_none() {
176 selected = current.meta.session_id.clone();
177 }
178 let fingerprint = source_fingerprint(&path, ¤t, selected.as_deref())?;
179
180 Ok(Self {
181 path,
182 opencode_session: selected,
183 fidelity,
184 current,
185 fingerprint,
186 initial_pending: true,
187 next_sequence: 1,
188 })
189 }
190
191 pub fn poll(&mut self) -> Result<Option<SessionWatchEvent>> {
196 if self.initial_pending {
197 self.initial_pending = false;
198 return Ok(Some(self.snapshot(SessionSnapshotReason::Initial)));
199 }
200
201 let observed =
202 source_fingerprint(&self.path, &self.current, self.opencode_session.as_deref())?;
203 if observed == self.fingerprint {
204 return Ok(None);
205 }
206
207 let sqlite = looks_like_sqlite(&self.path);
208 let loaded = load_selected(
209 &self.path,
210 self.opencode_session.as_deref(),
211 sqlite,
212 self.fidelity,
213 );
214 self.fingerprint = observed;
215 let next = match loaded {
216 Ok(session) if session.parse_error_lines > 0 => {
217 let count = session.parse_error_lines;
218 Some(self.watch_error(format!(
219 "{} contains {count} malformed or truncated JSON line(s); retaining the last good snapshot",
220 self.path.display()
221 )))
222 }
223 Err(error) => Some(self.watch_error(format!(
224 "could not reload {}: {error}; retaining the last good snapshot",
225 self.path.display()
226 ))),
227 Ok(session) => self.event_for_session(session),
228 };
229 Ok(next)
230 }
231
232 fn event_for_session(&mut self, session: Session) -> Option<SessionWatchEvent> {
233 if normalized_session_eq(&self.current, &session) {
234 self.current = session;
235 return None;
236 }
237
238 let identity_same = session_identity_eq(&self.current, &session);
239 let subagents_same = normalized_subagents_eq(&self.current, &session);
240 if identity_same
241 && subagents_same
242 && session.messages.len() > self.current.messages.len()
243 && session.messages.starts_with(&self.current.messages)
244 {
245 let messages = session.messages[self.current.messages.len()..].to_vec();
246 let session_id = session.meta.session_id.clone();
247 self.current = session;
248 return Some(SessionWatchEvent::MessagesAppended {
249 sequence: self.take_sequence(),
250 session_id,
251 messages,
252 });
253 }
254
255 let reason = if identity_same {
256 SessionSnapshotReason::HistoryRewritten
257 } else {
258 SessionSnapshotReason::SourceChanged
259 };
260 self.current = session;
261 Some(self.snapshot(reason))
262 }
263
264 fn snapshot(&mut self, reason: SessionSnapshotReason) -> SessionWatchEvent {
265 SessionWatchEvent::SessionSnapshot {
266 sequence: self.take_sequence(),
267 reason,
268 session: Box::new(self.current.clone()),
269 }
270 }
271
272 fn watch_error(&mut self, message: String) -> SessionWatchEvent {
273 SessionWatchEvent::WatchError {
274 sequence: self.take_sequence(),
275 message,
276 }
277 }
278
279 fn take_sequence(&mut self) -> u64 {
280 let sequence = self.next_sequence;
281 self.next_sequence += 1;
282 sequence
283 }
284}
285
286fn load_selected(
287 path: &Path,
288 selected: Option<&str>,
289 sqlite: bool,
290 fidelity: Fidelity,
291) -> Result<Session> {
292 if sqlite {
293 Session::from_opencode_sqlite(path, selected)
294 } else {
295 Session::load_with_fidelity(path, fidelity)
296 }
297}
298
299fn session_identity_eq(left: &Session, right: &Session) -> bool {
300 left.meta.source == right.meta.source
301 && left.meta.session_id == right.meta.session_id
302 && left.meta.model == right.meta.model
303 && left.meta.cwd == right.meta.cwd
304 && left.meta.system_prompt == right.meta.system_prompt
305 && left.meta.agent_id == right.meta.agent_id
306 && left.meta.parent_tool_use_id == right.meta.parent_tool_use_id
307 && left.meta.lineage == right.meta.lineage
308}
309
310fn normalized_session_eq(left: &Session, right: &Session) -> bool {
311 session_identity_eq(left, right)
312 && left.messages == right.messages
313 && normalized_subagents_eq(left, right)
314 && left.parse_error_lines == right.parse_error_lines
315 && left.load_residue == right.load_residue
316}
317
318fn normalized_subagents_eq(left: &Session, right: &Session) -> bool {
319 left.subagents.len() == right.subagents.len()
320 && left
321 .subagents
322 .iter()
323 .zip(&right.subagents)
324 .all(|(left, right)| normalized_session_eq(left, right))
325}
326
327fn source_name(source: SessionSource) -> &'static str {
328 match source {
329 SessionSource::ClaudeCode => "claude_code",
330 SessionSource::Codex => "codex",
331 SessionSource::OpenCode => "opencode",
332 SessionSource::Pi => "pi",
333 SessionSource::Grok => "grok",
334 SessionSource::Native => "native",
335 }
336}
337
338fn message_json(message: &ChatMessage) -> Value {
339 let mut value = serde_json::to_value(message).unwrap_or_else(|_| json!({}));
340 if let Value::Object(object) = &mut value {
341 object.insert("metadata".to_string(), json!(message.metadata));
342 }
343 value
344}
345
346pub fn normalized_session_json(session: &Session) -> Value {
348 json!({
349 "source": source_name(session.meta.source),
350 "session_id": session.meta.session_id,
351 "model": session.meta.model,
352 "cwd": session.meta.cwd,
353 "system_prompt": session.meta.system_prompt,
354 "agent_id": session.meta.agent_id,
355 "parent_tool_use_id": session.meta.parent_tool_use_id,
356 "lineage": session.meta.lineage,
357 "messages": session.messages.iter().map(message_json).collect::<Vec<_>>(),
358 "subagents": session.subagents.iter().map(normalized_session_json).collect::<Vec<_>>(),
359 "raw_record_count": session.raw.len(),
360 "parse_error_lines": session.parse_error_lines,
361 "fidelity": session.load_fidelity(),
366 "residue": session.load_residue,
367 })
368}
369
370#[derive(Debug, Clone, PartialEq, Eq)]
371struct PathStamp {
372 path: PathBuf,
373 kind: StampKind,
374 len: u64,
375 modified_nanos: Option<u128>,
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379enum StampKind {
380 Missing,
381 File,
382 Directory,
383 Other,
384}
385
386fn source_fingerprint(
387 path: &Path,
388 session: &Session,
389 selected_session: Option<&str>,
390) -> Result<Vec<PathStamp>> {
391 let mut stamps = vec![path_stamp(path)?];
392 match session.meta.source {
393 SessionSource::ClaudeCode => {
394 if let (Some(parent), Some(stem)) = (path.parent(), path.file_stem()) {
395 collect_tree_stamps(&parent.join(stem).join("subagents"), &mut stamps)?;
396 }
397 }
398 SessionSource::OpenCode if looks_like_sqlite(path) => {
399 stamps.push(path_stamp(&path_with_suffix(path, "-wal"))?);
400 stamps.push(path_stamp(&path_with_suffix(path, "-shm"))?);
401 if let (Some(parent), Some(session_id)) = (path.parent(), selected_session) {
402 stamps.push(path_stamp(
403 &parent
404 .join("storage")
405 .join("session_diff")
406 .join(format!("{session_id}.json")),
407 )?);
408 }
409 }
410 SessionSource::Grok => {
411 if let Some(parent) = path.parent() {
412 stamps.push(path_stamp(&parent.join("updates.jsonl"))?);
417 stamps.push(path_stamp(&parent.join("summary.json"))?);
418 }
419 }
420 _ => {}
421 }
422 stamps.sort_by(|left, right| left.path.cmp(&right.path));
423 Ok(stamps)
424}
425
426fn collect_tree_stamps(path: &Path, out: &mut Vec<PathStamp>) -> Result<()> {
427 collect_tree_stamps_inner(path, out, true)
428}
429
430fn collect_tree_stamps_inner(path: &Path, out: &mut Vec<PathStamp>, follow: bool) -> Result<()> {
431 let stamp = if follow {
432 path_stamp(path)?
433 } else {
434 path_stamp_no_follow(path)?
435 };
436 let is_directory = stamp.kind == StampKind::Directory;
437 out.push(stamp);
438 if !is_directory {
439 return Ok(());
440 }
441
442 let mut children = std::fs::read_dir(path)?.collect::<std::io::Result<Vec<_>>>()?;
443 children.sort_by_key(|entry| entry.path());
444 for child in children {
445 collect_tree_stamps_inner(&child.path(), out, false)?;
446 }
447 Ok(())
448}
449
450fn path_stamp(path: &Path) -> Result<PathStamp> {
451 path_stamp_with(path, |path| std::fs::metadata(path))
452}
453
454fn path_stamp_no_follow(path: &Path) -> Result<PathStamp> {
455 path_stamp_with(path, |path| std::fs::symlink_metadata(path))
456}
457
458fn path_stamp_with(
459 path: &Path,
460 metadata: impl FnOnce(&Path) -> std::io::Result<Metadata>,
461) -> Result<PathStamp> {
462 match metadata(path) {
463 Ok(metadata) => Ok(stamp_from_metadata(path, &metadata)),
464 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(PathStamp {
465 path: path.to_path_buf(),
466 kind: StampKind::Missing,
467 len: 0,
468 modified_nanos: None,
469 }),
470 Err(error) => Err(error.into()),
471 }
472}
473
474fn path_with_suffix(path: &Path, suffix: &str) -> PathBuf {
475 let mut value = path.as_os_str().to_os_string();
476 value.push(suffix);
477 PathBuf::from(value)
478}
479
480fn stamp_from_metadata(path: &Path, metadata: &Metadata) -> PathStamp {
481 let file_type = metadata.file_type();
482 let kind = if file_type.is_file() {
483 StampKind::File
484 } else if file_type.is_dir() {
485 StampKind::Directory
486 } else {
487 StampKind::Other
488 };
489 PathStamp {
490 path: path.to_path_buf(),
491 kind,
492 len: metadata.len(),
493 modified_nanos: metadata
494 .modified()
495 .ok()
496 .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
497 .map(|duration| duration.as_nanos()),
498 }
499}