Skip to main content

khive_pack_session/mirror/
service.rs

1//! Background live-mirror polling service.
2//!
3//! `run_mirror_service` is an infinite loop started by `SessionPack::warm()`.
4//! It discovers `*.jsonl` files under the Claude Code projects directory,
5//! tracks byte offsets, and tails new content every `poll_interval`.
6//!
7//! Design principles:
8//! - Infallible: a per-file error is logged and skipped; the loop continues.
9//! - Cheap when idle: each tick performs only `metadata().len()` stat calls;
10//!   actual reads happen only when the file has grown.
11//! - Idempotent: offset tracking + `INSERT OR IGNORE` in `mirror_file` ensure
12//!   running multiple times or restarting the daemon is safe.
13
14use std::collections::HashMap;
15use std::path::PathBuf;
16use std::time::Duration;
17
18use khive_runtime::{KhiveRuntime, RuntimeError};
19use khive_storage::types::{SqlStatement, SqlValue};
20
21use super::ingest::{self, MirrorSource};
22
23/// A discovered JSONL file together with its source type and (for Codex) the
24/// session UUID derived from the filename.
25struct DiscoveredFile {
26    path: PathBuf,
27    source: MirrorSource,
28    /// Set for `MirrorSource::Codex`; `None` for `MirrorSource::ClaudeCode`.
29    session_id: Option<String>,
30}
31
32/// Configuration for the mirror service.
33///
34/// Loaded from environment variables at daemon boot via `MirrorConfig::from_env`.
35pub struct MirrorConfig {
36    /// Whether the Claude Code transcript mirror is enabled (default: false — opt-in).
37    pub enabled: bool,
38    /// Root directory that contains `<project-slug>/<session-uuid>.jsonl` files.
39    ///
40    /// Defaults to `$HOME/.claude/projects`.
41    pub projects_dir: PathBuf,
42    /// Whether the Codex CLI transcript mirror is enabled (default: false — opt-in,
43    /// independent of `enabled`).
44    pub codex_enabled: bool,
45    /// Root directory that contains `YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl` files.
46    ///
47    /// Defaults to `$HOME/.codex/sessions`.
48    pub codex_sessions_dir: PathBuf,
49    /// How long to sleep between polling ticks (default: 2 seconds).
50    pub poll_interval: Duration,
51    /// When true (default), existing files are mirrored from byte offset 0.
52    /// When false, newly discovered files start mirroring from their current EOF.
53    pub backfill: bool,
54}
55
56impl MirrorConfig {
57    /// Build config from environment variables, falling back to safe defaults.
58    ///
59    /// | Variable                       | Default                        |
60    /// |--------------------------------|--------------------------------|
61    /// | `KHIVE_MIRROR_ENABLED`         | `false`                        |
62    /// | `KHIVE_MIRROR_PROJECTS_DIR`    | `$HOME/.claude/projects`       |
63    /// | `KHIVE_MIRROR_CODEX_ENABLED`   | `false`                        |
64    /// | `KHIVE_MIRROR_CODEX_DIR`       | `$HOME/.codex/sessions`        |
65    /// | `KHIVE_MIRROR_POLL_SECS`       | `2`                            |
66    /// | `KHIVE_MIRROR_BACKFILL`        | `true`                         |
67    pub fn from_env() -> Self {
68        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
69
70        let enabled = std::env::var("KHIVE_MIRROR_ENABLED")
71            .map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"))
72            .unwrap_or(false);
73
74        let projects_dir = std::env::var("KHIVE_MIRROR_PROJECTS_DIR")
75            .map(PathBuf::from)
76            .unwrap_or_else(|_| PathBuf::from(&home).join(".claude").join("projects"));
77
78        let codex_enabled = std::env::var("KHIVE_MIRROR_CODEX_ENABLED")
79            .map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"))
80            .unwrap_or(false);
81
82        let codex_sessions_dir = std::env::var("KHIVE_MIRROR_CODEX_DIR")
83            .map(PathBuf::from)
84            .unwrap_or_else(|_| PathBuf::from(&home).join(".codex").join("sessions"));
85
86        let poll_secs = std::env::var("KHIVE_MIRROR_POLL_SECS")
87            .ok()
88            .and_then(|v| v.parse::<u64>().ok())
89            .unwrap_or(2);
90
91        let backfill = std::env::var("KHIVE_MIRROR_BACKFILL")
92            .map(|v| !matches!(v.to_lowercase().as_str(), "0" | "false" | "no"))
93            .unwrap_or(true);
94
95        Self {
96            enabled,
97            projects_dir,
98            codex_enabled,
99            codex_sessions_dir,
100            poll_interval: Duration::from_secs(poll_secs),
101            backfill,
102        }
103    }
104}
105
106/// Infinite background polling loop.  Returns only on a fatal setup error.
107///
108/// Seed state from the `session_mirror_cursor` table at startup, then loop:
109/// stat each discovered file, tail any new bytes, sleep.
110///
111/// Claude Code and Codex mirrors are independent: each is enabled by its own
112/// flag and scanned separately each tick.
113///
114/// Per-file errors are logged with `tracing::warn!` and do NOT stop the loop.
115pub async fn run_mirror_service(runtime: KhiveRuntime, config: MirrorConfig) {
116    tracing::info!(
117        projects_dir = %config.projects_dir.display(),
118        codex_sessions_dir = %config.codex_sessions_dir.display(),
119        poll_interval_ms = config.poll_interval.as_millis(),
120        backfill = config.backfill,
121        cc_enabled = config.enabled,
122        codex_enabled = config.codex_enabled,
123        "session mirror service starting"
124    );
125
126    // Seed in-memory offsets from the persisted cursor table.
127    let mut offsets: HashMap<PathBuf, u64> = match load_cursors(&runtime).await {
128        Ok(map) => map,
129        Err(e) => {
130            tracing::warn!(error = %e, "session mirror: failed to load cursors (starting from empty)");
131            HashMap::new()
132        }
133    };
134
135    loop {
136        // Collect all files to process this tick.
137        let mut discovered: Vec<DiscoveredFile> = Vec::new();
138
139        if config.enabled {
140            for path in scan_cc_jsonl_files(&config.projects_dir) {
141                discovered.push(DiscoveredFile {
142                    path,
143                    source: MirrorSource::ClaudeCode,
144                    session_id: None,
145                });
146            }
147        }
148
149        if config.codex_enabled {
150            for item in scan_codex_jsonl_files(&config.codex_sessions_dir) {
151                discovered.push(item);
152            }
153        }
154
155        let total_tracked = discovered.len();
156        let mut files_mirrored: u64 = 0;
157        let mut rows_inserted: u64 = 0;
158
159        for item in &discovered {
160            // Seed offset for newly discovered files.
161            if !offsets.contains_key(&item.path) {
162                let start = if config.backfill {
163                    0
164                } else {
165                    std::fs::metadata(&item.path).map(|m| m.len()).unwrap_or(0)
166                };
167                offsets.insert(item.path.clone(), start);
168            }
169
170            let offset = *offsets.get(&item.path).unwrap_or(&0);
171
172            // Fast path: skip if file hasn't grown.
173            let file_len = match std::fs::metadata(&item.path).map(|m| m.len()) {
174                Ok(len) => len,
175                Err(e) => {
176                    tracing::warn!(path = %item.path.display(), error = %e, "session mirror: stat failed");
177                    continue;
178                }
179            };
180
181            if file_len <= offset {
182                continue;
183            }
184
185            // Tail the new bytes.
186            match ingest::mirror_file(
187                &runtime,
188                &item.path,
189                offset,
190                item.source,
191                item.session_id.as_deref(),
192            )
193            .await
194            {
195                Ok(stats) => {
196                    offsets.insert(item.path.clone(), stats.new_offset);
197                    if stats.inserted > 0 || stats.new_offset > offset {
198                        files_mirrored += 1;
199                        rows_inserted += stats.inserted;
200                        tracing::debug!(
201                            path = %item.path.display(),
202                            source = ?item.source,
203                            inserted = stats.inserted,
204                            scanned = stats.scanned,
205                            new_offset = stats.new_offset,
206                            "session mirror: tailed file"
207                        );
208                    }
209                }
210                Err(e) => {
211                    tracing::warn!(
212                        path = %item.path.display(),
213                        error = %e,
214                        "session mirror: per-file error (skipping)"
215                    );
216                }
217            }
218        }
219
220        if files_mirrored > 0 || rows_inserted > 0 {
221            tracing::info!(
222                files_mirrored,
223                rows_inserted,
224                total_tracked,
225                "session mirror tick"
226            );
227        } else {
228            tracing::debug!(total_tracked, "session mirror: quiet tick");
229        }
230
231        tokio::time::sleep(config.poll_interval).await;
232    }
233}
234
235/// Load persisted `(file_path, byte_offset)` pairs from `session_mirror_cursor`.
236///
237/// Missing table (e.g. schema not yet applied) returns an empty map rather
238/// than an error — the service self-bootstraps on the first successful write.
239async fn load_cursors(runtime: &KhiveRuntime) -> Result<HashMap<PathBuf, u64>, RuntimeError> {
240    let sql = runtime.sql();
241    let mut reader = sql
242        .reader()
243        .await
244        .map_err(|e| RuntimeError::Internal(format!("mirror: cursor reader: {e}")))?;
245
246    let rows = reader
247        .query_all(SqlStatement {
248            sql: "SELECT file_path, byte_offset FROM session_mirror_cursor".into(),
249            params: vec![],
250            label: Some("mirror_load_cursors".into()),
251        })
252        .await;
253
254    match rows {
255        Err(e) => {
256            // Table may not exist yet (schema applied lazily at first warm tick).
257            tracing::debug!(error = %e, "mirror: cursor table not yet available");
258            Ok(HashMap::new())
259        }
260        Ok(rows) => {
261            let mut map = HashMap::with_capacity(rows.len());
262            for row in rows {
263                let file_path = match row.get("file_path") {
264                    Some(SqlValue::Text(s)) => PathBuf::from(s),
265                    _ => continue,
266                };
267                let byte_offset = match row.get("byte_offset") {
268                    Some(SqlValue::Integer(n)) => *n as u64,
269                    _ => 0,
270                };
271                map.insert(file_path, byte_offset);
272            }
273            Ok(map)
274        }
275    }
276}
277
278/// Scan `projects_dir` for Claude Code `*.jsonl` files one level deep.
279///
280/// Expects the layout: `<projects_dir>/<project-slug>/<session-uuid>.jsonl`.
281/// Silently skips unreadable subdirectories.
282fn scan_cc_jsonl_files(projects_dir: &std::path::Path) -> Vec<PathBuf> {
283    let mut files = Vec::new();
284
285    let Ok(top_entries) = std::fs::read_dir(projects_dir) else {
286        return files;
287    };
288
289    for top_entry in top_entries.flatten() {
290        let slug_dir = top_entry.path();
291        if !slug_dir.is_dir() {
292            continue;
293        }
294        let Ok(sub_entries) = std::fs::read_dir(&slug_dir) else {
295            continue;
296        };
297        for sub_entry in sub_entries.flatten() {
298            let path = sub_entry.path();
299            if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
300                files.push(path);
301            }
302        }
303    }
304
305    files
306}
307
308/// Recursively scan `sessions_dir` for Codex `rollout-*.jsonl` files.
309///
310/// Expects the date-nested layout:
311/// `<sessions_dir>/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`.
312/// The session UUID is parsed from the filename stem.
313/// Silently skips unreadable directories or filenames that do not match the
314/// expected `rollout-…-<uuid>` pattern.
315fn scan_codex_jsonl_files(sessions_dir: &std::path::Path) -> Vec<DiscoveredFile> {
316    let mut files = Vec::new();
317    scan_codex_dir_recursive(sessions_dir, &mut files);
318    files
319}
320
321/// Recursive helper for `scan_codex_jsonl_files`.
322fn scan_codex_dir_recursive(dir: &std::path::Path, out: &mut Vec<DiscoveredFile>) {
323    let Ok(entries) = std::fs::read_dir(dir) else {
324        return;
325    };
326
327    for entry in entries.flatten() {
328        let path = entry.path();
329        if path.is_dir() {
330            scan_codex_dir_recursive(&path, out);
331        } else if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
332            if let Some(session_id) = extract_codex_session_id(&path) {
333                out.push(DiscoveredFile {
334                    path,
335                    source: MirrorSource::Codex,
336                    session_id: Some(session_id),
337                });
338            }
339        }
340    }
341}
342
343/// Extract the session UUID from a Codex filename of the form
344/// `rollout-<timestamp>-<uuid>.jsonl`.
345///
346/// Returns `None` for files whose name does not match the expected pattern or
347/// whose derived candidate is not a valid UUID.  Files whose stem looks like
348/// `rollout-2025-11-11T08-32-36` (no UUID suffix) are rejected here and
349/// silently skipped by the caller.
350fn extract_codex_session_id(path: &std::path::Path) -> Option<String> {
351    let stem = path.file_stem()?.to_str()?;
352    if !stem.starts_with("rollout-") {
353        return None;
354    }
355    // A standard UUID has 5 hyphen-delimited groups (8-4-4-4-12).
356    // Split the stem and take the last 5 segments as the UUID candidate.
357    let parts: Vec<&str> = stem.split('-').collect();
358    if parts.len() < 6 {
359        return None;
360    }
361    let candidate = parts[parts.len() - 5..].join("-");
362    // Validate structurally: reject timestamp-shaped junk like "2025-11-11T08-32-36"
363    // that also happens to have 4 hyphens.  uuid::Uuid::parse_str enforces the
364    // 8-4-4-4-12 hex-character layout.
365    match uuid::Uuid::parse_str(&candidate) {
366        Ok(_) => Some(candidate),
367        Err(_) => {
368            tracing::debug!(
369                path = %path.display(),
370                candidate,
371                "session mirror: codex filename did not yield a valid UUID — skipping"
372            );
373            None
374        }
375    }
376}
377
378#[cfg(test)]
379mod codex_filename_tests {
380    use super::extract_codex_session_id;
381    use std::path::Path;
382
383    #[test]
384    fn real_codex_filename_yields_uuid() {
385        let path =
386            Path::new("rollout-2025-11-11T08-32-36-019a731e-4a58-71b1-a71f-a8d2f9782113.jsonl");
387        assert_eq!(
388            extract_codex_session_id(path).as_deref(),
389            Some("019a731e-4a58-71b1-a71f-a8d2f9782113")
390        );
391    }
392
393    #[test]
394    fn timestamp_only_stem_is_rejected() {
395        // Regression for Finding 2: a stem with no UUID suffix has 4 hyphens
396        // in its trailing segments and must NOT be accepted as a session id.
397        let path = Path::new("rollout-2025-11-11T08-32-36.jsonl");
398        assert_eq!(extract_codex_session_id(path), None);
399    }
400
401    #[test]
402    fn invalid_hex_suffix_is_rejected() {
403        let path =
404            Path::new("rollout-2025-11-11T08-32-36-zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz.jsonl");
405        assert_eq!(extract_codex_session_id(path), None);
406    }
407
408    #[test]
409    fn too_short_suffix_is_rejected() {
410        let path = Path::new("rollout-2025-11-11T08-32-36-aaaa-bbbb-cccc-dddd.jsonl");
411        assert_eq!(extract_codex_session_id(path), None);
412    }
413
414    #[test]
415    fn non_rollout_filename_is_rejected() {
416        let path = Path::new("not-a-rollout-file.jsonl");
417        assert_eq!(extract_codex_session_id(path), None);
418    }
419}