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, LineTailSource};
22
23/// How a discovered file should be ingested.
24///
25/// `ChatGptExport` is a `MirrorSource` variant (ADR-080's closed mirror-source
26/// set) but deliberately not a `LineTailSource` variant: ChatGPT export
27/// ingestion is whole-file (`mirror_chatgpt_export_file`), not line-tail, so
28/// it does not belong in that narrower per-line dispatch enum.
29enum DiscoveredKind {
30    LineTail {
31        source: LineTailSource,
32        /// Set for `LineTailSource::Codex`; `None` for `LineTailSource::ClaudeCode`.
33        session_id: Option<String>,
34    },
35    ChatGptExport,
36}
37
38/// A discovered file together with how it should be ingested.
39struct DiscoveredFile {
40    path: PathBuf,
41    kind: DiscoveredKind,
42}
43
44/// Configuration for the mirror service.
45///
46/// Loaded from environment variables at daemon boot via `MirrorConfig::from_env`.
47pub struct MirrorConfig {
48    /// Whether the Claude Code transcript mirror is enabled (default: false — opt-in).
49    pub enabled: bool,
50    /// Root directory that contains `<project-slug>/<session-uuid>.jsonl` files.
51    ///
52    /// Defaults to `$HOME/.claude/projects`.
53    pub projects_dir: PathBuf,
54    /// Whether the Codex CLI transcript mirror is enabled (default: false — opt-in,
55    /// independent of `enabled`).
56    pub codex_enabled: bool,
57    /// Root directory that contains `YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl` files.
58    ///
59    /// Defaults to `$HOME/.codex/sessions`.
60    pub codex_sessions_dir: PathBuf,
61    /// Whether the ChatGPT export mirror is enabled (default: false — opt-in,
62    /// independent of `enabled` and `codex_enabled`).
63    pub chatgpt_enabled: bool,
64    /// Root directory scanned (recursively) for `conversations.json` export files.
65    ///
66    /// Defaults to `$HOME/.chatgpt/exports`.
67    pub chatgpt_exports_dir: PathBuf,
68    /// How long to sleep between polling ticks (default: 2 seconds).
69    pub poll_interval: Duration,
70    /// When true (default), existing files are mirrored from byte offset 0.
71    /// When false, newly discovered files start mirroring from their current EOF.
72    pub backfill: bool,
73}
74
75/// Default poll interval, in seconds, used when `KHIVE_MIRROR_POLL_SECS` is
76/// unset, non-numeric, or explicitly zero.
77const DEFAULT_MIRROR_POLL_SECS: u64 = 2;
78
79/// Parse `KHIVE_MIRROR_POLL_SECS`, rejecting an explicit `0` (which would
80/// otherwise create a hot polling loop) and falling back to
81/// `DEFAULT_MIRROR_POLL_SECS` for missing, non-numeric, or zero values.
82///
83/// Explicit zero and non-numeric input are logged as distinct warnings so an
84/// operator can tell which mistake they made.
85fn parse_mirror_poll_secs(raw: Option<&str>) -> u64 {
86    match raw {
87        None => DEFAULT_MIRROR_POLL_SECS,
88        Some(v) => match v.parse::<u64>() {
89            Ok(0) => {
90                tracing::warn!(
91                    value = v,
92                    default_secs = DEFAULT_MIRROR_POLL_SECS,
93                    "KHIVE_MIRROR_POLL_SECS must be nonzero; using default"
94                );
95                DEFAULT_MIRROR_POLL_SECS
96            }
97            Ok(secs) => secs,
98            Err(_) => {
99                tracing::warn!(
100                    value = v,
101                    default_secs = DEFAULT_MIRROR_POLL_SECS,
102                    "KHIVE_MIRROR_POLL_SECS is not numeric; using default"
103                );
104                DEFAULT_MIRROR_POLL_SECS
105            }
106        },
107    }
108}
109
110impl MirrorConfig {
111    /// Build config from environment variables, falling back to safe defaults.
112    ///
113    /// | Variable                       | Default                        |
114    /// |--------------------------------|--------------------------------|
115    /// | `KHIVE_MIRROR_ENABLED`         | `false`                        |
116    /// | `KHIVE_MIRROR_PROJECTS_DIR`    | `$HOME/.claude/projects`       |
117    /// | `KHIVE_MIRROR_CODEX_ENABLED`   | `false`                        |
118    /// | `KHIVE_MIRROR_CODEX_DIR`       | `$HOME/.codex/sessions`        |
119    /// | `KHIVE_MIRROR_CHATGPT_ENABLED` | `false`                        |
120    /// | `KHIVE_MIRROR_CHATGPT_DIR`     | `$HOME/.chatgpt/exports`       |
121    /// | `KHIVE_MIRROR_POLL_SECS`       | `2`                            |
122    /// | `KHIVE_MIRROR_BACKFILL`        | `true`                         |
123    pub fn from_env() -> Self {
124        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
125
126        let enabled = std::env::var("KHIVE_MIRROR_ENABLED")
127            .map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"))
128            .unwrap_or(false);
129
130        let projects_dir = std::env::var("KHIVE_MIRROR_PROJECTS_DIR")
131            .map(PathBuf::from)
132            .unwrap_or_else(|_| PathBuf::from(&home).join(".claude").join("projects"));
133
134        let codex_enabled = std::env::var("KHIVE_MIRROR_CODEX_ENABLED")
135            .map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"))
136            .unwrap_or(false);
137
138        let codex_sessions_dir = std::env::var("KHIVE_MIRROR_CODEX_DIR")
139            .map(PathBuf::from)
140            .unwrap_or_else(|_| PathBuf::from(&home).join(".codex").join("sessions"));
141
142        let chatgpt_enabled = std::env::var("KHIVE_MIRROR_CHATGPT_ENABLED")
143            .map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"))
144            .unwrap_or(false);
145
146        let chatgpt_exports_dir = std::env::var("KHIVE_MIRROR_CHATGPT_DIR")
147            .map(PathBuf::from)
148            .unwrap_or_else(|_| PathBuf::from(&home).join(".chatgpt").join("exports"));
149
150        let poll_raw = std::env::var("KHIVE_MIRROR_POLL_SECS").ok();
151        let poll_secs = parse_mirror_poll_secs(poll_raw.as_deref());
152
153        let backfill = std::env::var("KHIVE_MIRROR_BACKFILL")
154            .map(|v| !matches!(v.to_lowercase().as_str(), "0" | "false" | "no"))
155            .unwrap_or(true);
156
157        Self {
158            enabled,
159            projects_dir,
160            codex_enabled,
161            codex_sessions_dir,
162            chatgpt_enabled,
163            chatgpt_exports_dir,
164            poll_interval: Duration::from_secs(poll_secs),
165            backfill,
166        }
167    }
168}
169
170#[cfg(test)]
171mod config_tests {
172    use super::parse_mirror_poll_secs;
173
174    /// Regression for PACKSESSION-AUD-002: `KHIVE_MIRROR_POLL_SECS=0` used to
175    /// produce a hot polling loop via `Duration::from_secs(0)`. Explicit zero
176    /// must now be rejected back to the documented default, and the default
177    /// must remain distinguishable from a non-numeric value.
178    #[test]
179    fn poll_secs_zero_is_rejected_and_default_remains_two_seconds() {
180        assert_eq!(
181            parse_mirror_poll_secs(None),
182            2,
183            "missing value defaults to 2s"
184        );
185        assert_eq!(
186            parse_mirror_poll_secs(Some("abc")),
187            2,
188            "non-numeric value defaults to 2s"
189        );
190        assert_eq!(
191            parse_mirror_poll_secs(Some("0")),
192            2,
193            "explicit zero must be rejected back to the default, not accepted as a hot loop"
194        );
195        assert_eq!(
196            parse_mirror_poll_secs(Some("1")),
197            1,
198            "valid nonzero value is honored"
199        );
200        assert_eq!(
201            parse_mirror_poll_secs(Some("5")),
202            5,
203            "valid nonzero value is honored"
204        );
205    }
206}
207
208/// Infinite background polling loop.  Returns only on a fatal setup error.
209///
210/// Seed state from the `session_mirror_cursor` table at startup, then loop:
211/// stat each discovered file, tail any new bytes, sleep.
212///
213/// Claude Code and Codex mirrors are independent: each is enabled by its own
214/// flag and scanned separately each tick.
215///
216/// Per-file errors are logged with `tracing::warn!` and do NOT stop the loop.
217pub async fn run_mirror_service(runtime: KhiveRuntime, config: MirrorConfig) {
218    tracing::info!(
219        projects_dir = %config.projects_dir.display(),
220        codex_sessions_dir = %config.codex_sessions_dir.display(),
221        poll_interval_ms = config.poll_interval.as_millis(),
222        backfill = config.backfill,
223        cc_enabled = config.enabled,
224        codex_enabled = config.codex_enabled,
225        "session mirror service starting"
226    );
227
228    // Seed in-memory offsets from the persisted cursor table.
229    let mut offsets: HashMap<PathBuf, u64> = match load_cursors(&runtime).await {
230        Ok(map) => map,
231        Err(e) => {
232            tracing::warn!(error = %e, "session mirror: failed to load cursors (starting from empty)");
233            HashMap::new()
234        }
235    };
236
237    loop {
238        // Collect all files to process this tick.
239        let mut discovered: Vec<DiscoveredFile> = Vec::new();
240
241        if config.enabled {
242            for path in scan_cc_jsonl_files(&config.projects_dir) {
243                discovered.push(DiscoveredFile {
244                    path,
245                    kind: DiscoveredKind::LineTail {
246                        source: LineTailSource::ClaudeCode,
247                        session_id: None,
248                    },
249                });
250            }
251        }
252
253        if config.codex_enabled {
254            for item in scan_codex_jsonl_files(&config.codex_sessions_dir) {
255                discovered.push(item);
256            }
257        }
258
259        if config.chatgpt_enabled {
260            for item in scan_chatgpt_conversations_files(&config.chatgpt_exports_dir) {
261                discovered.push(item);
262            }
263        }
264
265        let total_tracked = discovered.len();
266        let mut files_mirrored: u64 = 0;
267        let mut rows_inserted: u64 = 0;
268
269        for item in &discovered {
270            // Seed offset for newly discovered files.
271            if !offsets.contains_key(&item.path) {
272                let start = if config.backfill {
273                    0
274                } else {
275                    std::fs::metadata(&item.path).map(|m| m.len()).unwrap_or(0)
276                };
277                offsets.insert(item.path.clone(), start);
278            }
279
280            let offset = *offsets.get(&item.path).unwrap_or(&0);
281
282            // Fast path: skip if file hasn't grown.
283            let file_len = match std::fs::metadata(&item.path).map(|m| m.len()) {
284                Ok(len) => len,
285                Err(e) => {
286                    tracing::warn!(path = %item.path.display(), error = %e, "session mirror: stat failed");
287                    continue;
288                }
289            };
290
291            if file_len <= offset {
292                continue;
293            }
294
295            // Tail (line-tail sources) or whole-file re-read (ChatGPT export).
296            let result = match &item.kind {
297                DiscoveredKind::LineTail { source, session_id } => {
298                    ingest::mirror_file(
299                        &runtime,
300                        &item.path,
301                        offset,
302                        *source,
303                        session_id.as_deref(),
304                    )
305                    .await
306                }
307                DiscoveredKind::ChatGptExport => {
308                    ingest::mirror_chatgpt_export_file(&runtime, &item.path, offset).await
309                }
310            };
311
312            match result {
313                Ok(stats) => {
314                    offsets.insert(item.path.clone(), stats.new_offset);
315                    if stats.inserted > 0 || stats.new_offset > offset {
316                        files_mirrored += 1;
317                        rows_inserted += stats.inserted;
318                        tracing::debug!(
319                            path = %item.path.display(),
320                            inserted = stats.inserted,
321                            scanned = stats.scanned,
322                            new_offset = stats.new_offset,
323                            "session mirror: tailed file"
324                        );
325                    }
326                }
327                Err(e) => {
328                    tracing::warn!(
329                        path = %item.path.display(),
330                        error = %e,
331                        "session mirror: per-file error (skipping)"
332                    );
333                }
334            }
335        }
336
337        if files_mirrored > 0 || rows_inserted > 0 {
338            tracing::info!(
339                files_mirrored,
340                rows_inserted,
341                total_tracked,
342                "session mirror tick"
343            );
344        } else {
345            tracing::debug!(total_tracked, "session mirror: quiet tick");
346        }
347
348        tokio::time::sleep(config.poll_interval).await;
349    }
350}
351
352/// Load persisted `(file_path, byte_offset)` pairs from `session_mirror_cursor`.
353///
354/// Missing table (e.g. schema not yet applied) returns an empty map rather
355/// than an error — the service self-bootstraps on the first successful write.
356async fn load_cursors(runtime: &KhiveRuntime) -> Result<HashMap<PathBuf, u64>, RuntimeError> {
357    let sql = runtime.sql();
358    let mut reader = sql
359        .reader()
360        .await
361        .map_err(|e| RuntimeError::Internal(format!("mirror: cursor reader: {e}")))?;
362
363    let rows = reader
364        .query_all(SqlStatement {
365            sql: "SELECT file_path, byte_offset FROM session_mirror_cursor".into(),
366            params: vec![],
367            label: Some("mirror_load_cursors".into()),
368        })
369        .await;
370
371    match rows {
372        Err(e) => {
373            // Table may not exist yet (schema applied lazily at first warm tick).
374            tracing::debug!(error = %e, "mirror: cursor table not yet available");
375            Ok(HashMap::new())
376        }
377        Ok(rows) => {
378            let mut map = HashMap::with_capacity(rows.len());
379            for row in rows {
380                let file_path = match row.get("file_path") {
381                    Some(SqlValue::Text(s)) => PathBuf::from(s),
382                    _ => continue,
383                };
384                let byte_offset = match row.get("byte_offset") {
385                    Some(SqlValue::Integer(n)) => *n as u64,
386                    _ => 0,
387                };
388                map.insert(file_path, byte_offset);
389            }
390            Ok(map)
391        }
392    }
393}
394
395/// Scan `projects_dir` for Claude Code `*.jsonl` files one level deep.
396///
397/// Expects the layout: `<projects_dir>/<project-slug>/<session-uuid>.jsonl`.
398/// Silently skips unreadable subdirectories.
399fn scan_cc_jsonl_files(projects_dir: &std::path::Path) -> Vec<PathBuf> {
400    let mut files = Vec::new();
401
402    let Ok(top_entries) = std::fs::read_dir(projects_dir) else {
403        return files;
404    };
405
406    for top_entry in top_entries.flatten() {
407        let slug_dir = top_entry.path();
408        if !slug_dir.is_dir() {
409            continue;
410        }
411        let Ok(sub_entries) = std::fs::read_dir(&slug_dir) else {
412            continue;
413        };
414        for sub_entry in sub_entries.flatten() {
415            let path = sub_entry.path();
416            if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
417                files.push(path);
418            }
419        }
420    }
421
422    files
423}
424
425/// Recursively scan `sessions_dir` for Codex `rollout-*.jsonl` files.
426///
427/// Expects the date-nested layout:
428/// `<sessions_dir>/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`.
429/// The session UUID is parsed from the filename stem.
430/// Silently skips unreadable directories or filenames that do not match the
431/// expected `rollout-…-<uuid>` pattern.
432fn scan_codex_jsonl_files(sessions_dir: &std::path::Path) -> Vec<DiscoveredFile> {
433    let mut files = Vec::new();
434    scan_codex_dir_recursive(sessions_dir, &mut files);
435    files
436}
437
438/// Recursive helper for `scan_codex_jsonl_files`.
439fn scan_codex_dir_recursive(dir: &std::path::Path, out: &mut Vec<DiscoveredFile>) {
440    let Ok(entries) = std::fs::read_dir(dir) else {
441        return;
442    };
443
444    for entry in entries.flatten() {
445        let path = entry.path();
446        if path.is_dir() {
447            scan_codex_dir_recursive(&path, out);
448        } else if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
449            if let Some(session_id) = extract_codex_session_id(&path) {
450                out.push(DiscoveredFile {
451                    path,
452                    kind: DiscoveredKind::LineTail {
453                        source: LineTailSource::Codex,
454                        session_id: Some(session_id),
455                    },
456                });
457            }
458        }
459    }
460}
461
462/// Find `conversations.json` ChatGPT export files under `path`.
463///
464/// If `path` is itself a file, it is accepted only when its basename is
465/// exactly `conversations.json`; otherwise `path` is scanned recursively.
466/// Silently skips unreadable directories, like the other scanners.
467fn scan_chatgpt_conversations_files(path: &std::path::Path) -> Vec<DiscoveredFile> {
468    let mut files = Vec::new();
469    if path.is_file() {
470        if path.file_name().and_then(|n| n.to_str()) == Some("conversations.json") {
471            files.push(DiscoveredFile {
472                path: path.to_path_buf(),
473                kind: DiscoveredKind::ChatGptExport,
474            });
475        }
476        return files;
477    }
478    scan_chatgpt_dir_recursive(path, &mut files);
479    files
480}
481
482/// Recursive helper for `scan_chatgpt_conversations_files`.
483fn scan_chatgpt_dir_recursive(dir: &std::path::Path, out: &mut Vec<DiscoveredFile>) {
484    let Ok(entries) = std::fs::read_dir(dir) else {
485        return;
486    };
487
488    for entry in entries.flatten() {
489        let path = entry.path();
490        if path.is_dir() {
491            scan_chatgpt_dir_recursive(&path, out);
492        } else if path.file_name().and_then(|n| n.to_str()) == Some("conversations.json") {
493            out.push(DiscoveredFile {
494                path,
495                kind: DiscoveredKind::ChatGptExport,
496            });
497        }
498    }
499}
500
501/// Extract the session UUID from a Codex filename of the form
502/// `rollout-<timestamp>-<uuid>.jsonl`.
503///
504/// Returns `None` for files whose name does not match the expected pattern or
505/// whose derived candidate is not a valid UUID.  Files whose stem looks like
506/// `rollout-2025-11-11T08-32-36` (no UUID suffix) are rejected here and
507/// silently skipped by the caller.
508fn extract_codex_session_id(path: &std::path::Path) -> Option<String> {
509    let stem = path.file_stem()?.to_str()?;
510    if !stem.starts_with("rollout-") {
511        return None;
512    }
513    // A standard UUID has 5 hyphen-delimited groups (8-4-4-4-12).
514    // Split the stem and take the last 5 segments as the UUID candidate.
515    let parts: Vec<&str> = stem.split('-').collect();
516    if parts.len() < 6 {
517        return None;
518    }
519    let candidate = parts[parts.len() - 5..].join("-");
520    // Validate structurally: reject timestamp-shaped junk like "2025-11-11T08-32-36"
521    // that also happens to have 4 hyphens.  uuid::Uuid::parse_str enforces the
522    // 8-4-4-4-12 hex-character layout.
523    match uuid::Uuid::parse_str(&candidate) {
524        Ok(_) => Some(candidate),
525        Err(_) => {
526            tracing::debug!(
527                path = %path.display(),
528                candidate,
529                "session mirror: codex filename did not yield a valid UUID — skipping"
530            );
531            None
532        }
533    }
534}
535
536#[cfg(test)]
537mod codex_filename_tests {
538    use super::extract_codex_session_id;
539    use std::path::Path;
540
541    #[test]
542    fn real_codex_filename_yields_uuid() {
543        let path =
544            Path::new("rollout-2025-11-11T08-32-36-019a731e-4a58-71b1-a71f-a8d2f9782113.jsonl");
545        assert_eq!(
546            extract_codex_session_id(path).as_deref(),
547            Some("019a731e-4a58-71b1-a71f-a8d2f9782113")
548        );
549    }
550
551    #[test]
552    fn timestamp_only_stem_is_rejected() {
553        // Regression: a stem with no UUID suffix has 4 hyphens
554        // in its trailing segments and must NOT be accepted as a session id.
555        let path = Path::new("rollout-2025-11-11T08-32-36.jsonl");
556        assert_eq!(extract_codex_session_id(path), None);
557    }
558
559    #[test]
560    fn invalid_hex_suffix_is_rejected() {
561        let path =
562            Path::new("rollout-2025-11-11T08-32-36-zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz.jsonl");
563        assert_eq!(extract_codex_session_id(path), None);
564    }
565
566    #[test]
567    fn too_short_suffix_is_rejected() {
568        let path = Path::new("rollout-2025-11-11T08-32-36-aaaa-bbbb-cccc-dddd.jsonl");
569        assert_eq!(extract_codex_session_id(path), None);
570    }
571
572    #[test]
573    fn non_rollout_filename_is_rejected() {
574        let path = Path::new("not-a-rollout-file.jsonl");
575        assert_eq!(extract_codex_session_id(path), None);
576    }
577}