Skip to main content

supercode/
watch.rs

1//! Passive, change-triggered following of local coding-harness sessions.
2//!
3//! This module deliberately observes persisted session state; it does not
4//! attach to, control, or infer the liveness of the process writing it.
5
6use 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/// Why a watcher emitted a complete session snapshot.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SessionSnapshotReason {
19    /// The first event emitted after opening the follower.
20    Initial,
21    /// Existing normalized history changed, disappeared, or branched.
22    HistoryRewritten,
23    /// Session identity or other non-message state changed.
24    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/// A normalized event emitted while following a local session.
38#[derive(Debug, Clone)]
39pub enum SessionWatchEvent {
40    /// A complete normalized view of the selected session.
41    SessionSnapshot {
42        /// Monotonically increasing sequence number, starting at one.
43        sequence: u64,
44        /// Why the full snapshot was necessary.
45        reason: SessionSnapshotReason,
46        /// The current normalized session.
47        session: Box<Session>,
48    },
49    /// Messages appended without changing existing normalized history.
50    MessagesAppended {
51        /// Monotonically increasing sequence number.
52        sequence: u64,
53        /// Selected session id, when the source records one.
54        session_id: Option<String>,
55        /// Newly appended normalized messages.
56        messages: Vec<ChatMessage>,
57    },
58    /// A recoverable read or parse problem. The follower remains usable.
59    WatchError {
60        /// Monotonically increasing sequence number.
61        sequence: u64,
62        /// Human-readable description of the problem.
63        message: String,
64    },
65}
66
67impl SessionWatchEvent {
68    /// The event's monotonic sequence number.
69    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    /// Render this event as one self-contained JSON value suitable for NDJSON.
78    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
110/// Poll-based follower for one persisted Claude Code, Codex, Pi, OpenCode, or Grok
111/// session.
112///
113/// Polling first compares cheap filesystem stamps. The source is fully parsed
114/// only after a relevant file changes. This keeps idle polling cheap while
115/// retaining the existing, well-tested format loaders as the source of truth.
116pub 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    /// Open a persisted session using its durable catalog locator.
128    pub fn open_locator(locator: &SessionLocator) -> Result<Self> {
129        Self::open_locator_with_fidelity(locator, Fidelity::ByteLossless)
130    }
131
132    /// [`Self::open_locator`] at a declared fidelity.
133    ///
134    /// A read-only mirror follows at [`Fidelity::Semantic`] so a compacted
135    /// transcript keeps streaming instead of turning every poll into a
136    /// `watch_error`. See [`crate::Session::load_with_fidelity`].
137    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    /// Open a local session for passive following.
150    ///
151    /// `opencode_session` is valid only for an OpenCode SQLite store. When it
152    /// is omitted, the initially selected session is pinned for all later
153    /// polls rather than following whichever database row becomes newest.
154    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    /// [`Self::open`] at a declared fidelity.
159    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, &current, 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    /// Inspect the filesystem once and return the next event, if any.
192    ///
193    /// The first call always returns an initial snapshot. Later calls return
194    /// `None` while the relevant filesystem stamps are unchanged.
195    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
346/// Render a normalized session as a language-neutral JSON value.
347pub 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        // Same pair `harness.v1.sessions.export` reports for an artifact: the
362        // level reached, and exactly what was given up to reach it. `semantic`
363        // with a non-empty residue means this is a read-only VIEW of a
364        // transcript that cannot be losslessly reconstructed.
365        "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                // `chat_history.jsonl` is the resumable transcript. The
413                // adjacent update stream and summary are cheap companion
414                // stamps that make a running Grok session wake the follower
415                // even while it is between committed transcript turns.
416                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}