use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use khive_runtime::{KhiveRuntime, RuntimeError};
use khive_storage::types::{SqlStatement, SqlValue};
use super::ingest::{self, LineTailSource};
enum DiscoveredKind {
LineTail {
source: LineTailSource,
session_id: Option<String>,
},
ChatGptExport,
}
struct DiscoveredFile {
path: PathBuf,
kind: DiscoveredKind,
}
pub struct MirrorConfig {
pub enabled: bool,
pub projects_dir: PathBuf,
pub codex_enabled: bool,
pub codex_sessions_dir: PathBuf,
pub chatgpt_enabled: bool,
pub chatgpt_exports_dir: PathBuf,
pub poll_interval: Duration,
pub backfill: bool,
}
const DEFAULT_MIRROR_POLL_SECS: u64 = 2;
fn parse_mirror_poll_secs(raw: Option<&str>) -> u64 {
match raw {
None => DEFAULT_MIRROR_POLL_SECS,
Some(v) => match v.parse::<u64>() {
Ok(0) => {
tracing::warn!(
value = v,
default_secs = DEFAULT_MIRROR_POLL_SECS,
"KHIVE_MIRROR_POLL_SECS must be nonzero; using default"
);
DEFAULT_MIRROR_POLL_SECS
}
Ok(secs) => secs,
Err(_) => {
tracing::warn!(
value = v,
default_secs = DEFAULT_MIRROR_POLL_SECS,
"KHIVE_MIRROR_POLL_SECS is not numeric; using default"
);
DEFAULT_MIRROR_POLL_SECS
}
},
}
}
impl MirrorConfig {
pub fn from_env() -> Self {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
let enabled = std::env::var("KHIVE_MIRROR_ENABLED")
.map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false);
let projects_dir = std::env::var("KHIVE_MIRROR_PROJECTS_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(&home).join(".claude").join("projects"));
let codex_enabled = std::env::var("KHIVE_MIRROR_CODEX_ENABLED")
.map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false);
let codex_sessions_dir = std::env::var("KHIVE_MIRROR_CODEX_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(&home).join(".codex").join("sessions"));
let chatgpt_enabled = std::env::var("KHIVE_MIRROR_CHATGPT_ENABLED")
.map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false);
let chatgpt_exports_dir = std::env::var("KHIVE_MIRROR_CHATGPT_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(&home).join(".chatgpt").join("exports"));
let poll_raw = std::env::var("KHIVE_MIRROR_POLL_SECS").ok();
let poll_secs = parse_mirror_poll_secs(poll_raw.as_deref());
let backfill = std::env::var("KHIVE_MIRROR_BACKFILL")
.map(|v| !matches!(v.to_lowercase().as_str(), "0" | "false" | "no"))
.unwrap_or(true);
Self {
enabled,
projects_dir,
codex_enabled,
codex_sessions_dir,
chatgpt_enabled,
chatgpt_exports_dir,
poll_interval: Duration::from_secs(poll_secs),
backfill,
}
}
}
#[cfg(test)]
mod config_tests {
use super::parse_mirror_poll_secs;
#[test]
fn poll_secs_zero_is_rejected_and_default_remains_two_seconds() {
assert_eq!(
parse_mirror_poll_secs(None),
2,
"missing value defaults to 2s"
);
assert_eq!(
parse_mirror_poll_secs(Some("abc")),
2,
"non-numeric value defaults to 2s"
);
assert_eq!(
parse_mirror_poll_secs(Some("0")),
2,
"explicit zero must be rejected back to the default, not accepted as a hot loop"
);
assert_eq!(
parse_mirror_poll_secs(Some("1")),
1,
"valid nonzero value is honored"
);
assert_eq!(
parse_mirror_poll_secs(Some("5")),
5,
"valid nonzero value is honored"
);
}
}
pub async fn run_mirror_service(runtime: KhiveRuntime, config: MirrorConfig) {
tracing::info!(
projects_dir = %config.projects_dir.display(),
codex_sessions_dir = %config.codex_sessions_dir.display(),
poll_interval_ms = config.poll_interval.as_millis(),
backfill = config.backfill,
cc_enabled = config.enabled,
codex_enabled = config.codex_enabled,
"session mirror service starting"
);
let mut offsets: HashMap<PathBuf, u64> = match load_cursors(&runtime).await {
Ok(map) => map,
Err(e) => {
tracing::warn!(error = %e, "session mirror: failed to load cursors (starting from empty)");
HashMap::new()
}
};
loop {
let mut discovered: Vec<DiscoveredFile> = Vec::new();
if config.enabled {
for path in scan_cc_jsonl_files(&config.projects_dir) {
discovered.push(DiscoveredFile {
path,
kind: DiscoveredKind::LineTail {
source: LineTailSource::ClaudeCode,
session_id: None,
},
});
}
}
if config.codex_enabled {
for item in scan_codex_jsonl_files(&config.codex_sessions_dir) {
discovered.push(item);
}
}
if config.chatgpt_enabled {
for item in scan_chatgpt_conversations_files(&config.chatgpt_exports_dir) {
discovered.push(item);
}
}
let total_tracked = discovered.len();
let mut files_mirrored: u64 = 0;
let mut rows_inserted: u64 = 0;
for item in &discovered {
if !offsets.contains_key(&item.path) {
let start = if config.backfill {
0
} else {
std::fs::metadata(&item.path).map(|m| m.len()).unwrap_or(0)
};
offsets.insert(item.path.clone(), start);
}
let offset = *offsets.get(&item.path).unwrap_or(&0);
let file_len = match std::fs::metadata(&item.path).map(|m| m.len()) {
Ok(len) => len,
Err(e) => {
tracing::warn!(path = %item.path.display(), error = %e, "session mirror: stat failed");
continue;
}
};
if file_len <= offset {
continue;
}
let result = match &item.kind {
DiscoveredKind::LineTail { source, session_id } => {
ingest::mirror_file(
&runtime,
&item.path,
offset,
*source,
session_id.as_deref(),
)
.await
}
DiscoveredKind::ChatGptExport => {
ingest::mirror_chatgpt_export_file(&runtime, &item.path, offset).await
}
};
match result {
Ok(stats) => {
offsets.insert(item.path.clone(), stats.new_offset);
if stats.inserted > 0 || stats.new_offset > offset {
files_mirrored += 1;
rows_inserted += stats.inserted;
tracing::debug!(
path = %item.path.display(),
inserted = stats.inserted,
scanned = stats.scanned,
new_offset = stats.new_offset,
"session mirror: tailed file"
);
}
}
Err(e) => {
tracing::warn!(
path = %item.path.display(),
error = %e,
"session mirror: per-file error (skipping)"
);
}
}
}
if files_mirrored > 0 || rows_inserted > 0 {
tracing::info!(
files_mirrored,
rows_inserted,
total_tracked,
"session mirror tick"
);
} else {
tracing::debug!(total_tracked, "session mirror: quiet tick");
}
tokio::time::sleep(config.poll_interval).await;
}
}
async fn load_cursors(runtime: &KhiveRuntime) -> Result<HashMap<PathBuf, u64>, RuntimeError> {
let sql = runtime.sql();
let mut reader = sql
.reader()
.await
.map_err(|e| RuntimeError::Internal(format!("mirror: cursor reader: {e}")))?;
let rows = reader
.query_all(SqlStatement {
sql: "SELECT file_path, byte_offset FROM session_mirror_cursor".into(),
params: vec![],
label: Some("mirror_load_cursors".into()),
})
.await;
match rows {
Err(e) => {
tracing::debug!(error = %e, "mirror: cursor table not yet available");
Ok(HashMap::new())
}
Ok(rows) => {
let mut map = HashMap::with_capacity(rows.len());
for row in rows {
let file_path = match row.get("file_path") {
Some(SqlValue::Text(s)) => PathBuf::from(s),
_ => continue,
};
let byte_offset = match row.get("byte_offset") {
Some(SqlValue::Integer(n)) => *n as u64,
_ => 0,
};
map.insert(file_path, byte_offset);
}
Ok(map)
}
}
}
fn scan_cc_jsonl_files(projects_dir: &std::path::Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let Ok(top_entries) = std::fs::read_dir(projects_dir) else {
return files;
};
for top_entry in top_entries.flatten() {
let slug_dir = top_entry.path();
if !slug_dir.is_dir() {
continue;
}
let Ok(sub_entries) = std::fs::read_dir(&slug_dir) else {
continue;
};
for sub_entry in sub_entries.flatten() {
let path = sub_entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
files.push(path);
}
}
}
files
}
fn scan_codex_jsonl_files(sessions_dir: &std::path::Path) -> Vec<DiscoveredFile> {
let mut files = Vec::new();
scan_codex_dir_recursive(sessions_dir, &mut files);
files
}
fn scan_codex_dir_recursive(dir: &std::path::Path, out: &mut Vec<DiscoveredFile>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_codex_dir_recursive(&path, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
if let Some(session_id) = extract_codex_session_id(&path) {
out.push(DiscoveredFile {
path,
kind: DiscoveredKind::LineTail {
source: LineTailSource::Codex,
session_id: Some(session_id),
},
});
}
}
}
}
fn scan_chatgpt_conversations_files(path: &std::path::Path) -> Vec<DiscoveredFile> {
let mut files = Vec::new();
if path.is_file() {
if path.file_name().and_then(|n| n.to_str()) == Some("conversations.json") {
files.push(DiscoveredFile {
path: path.to_path_buf(),
kind: DiscoveredKind::ChatGptExport,
});
}
return files;
}
scan_chatgpt_dir_recursive(path, &mut files);
files
}
fn scan_chatgpt_dir_recursive(dir: &std::path::Path, out: &mut Vec<DiscoveredFile>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_chatgpt_dir_recursive(&path, out);
} else if path.file_name().and_then(|n| n.to_str()) == Some("conversations.json") {
out.push(DiscoveredFile {
path,
kind: DiscoveredKind::ChatGptExport,
});
}
}
}
fn extract_codex_session_id(path: &std::path::Path) -> Option<String> {
let stem = path.file_stem()?.to_str()?;
if !stem.starts_with("rollout-") {
return None;
}
let parts: Vec<&str> = stem.split('-').collect();
if parts.len() < 6 {
return None;
}
let candidate = parts[parts.len() - 5..].join("-");
match uuid::Uuid::parse_str(&candidate) {
Ok(_) => Some(candidate),
Err(_) => {
tracing::debug!(
path = %path.display(),
candidate,
"session mirror: codex filename did not yield a valid UUID — skipping"
);
None
}
}
}
#[cfg(test)]
mod codex_filename_tests {
use super::extract_codex_session_id;
use std::path::Path;
#[test]
fn real_codex_filename_yields_uuid() {
let path =
Path::new("rollout-2025-11-11T08-32-36-019a731e-4a58-71b1-a71f-a8d2f9782113.jsonl");
assert_eq!(
extract_codex_session_id(path).as_deref(),
Some("019a731e-4a58-71b1-a71f-a8d2f9782113")
);
}
#[test]
fn timestamp_only_stem_is_rejected() {
let path = Path::new("rollout-2025-11-11T08-32-36.jsonl");
assert_eq!(extract_codex_session_id(path), None);
}
#[test]
fn invalid_hex_suffix_is_rejected() {
let path =
Path::new("rollout-2025-11-11T08-32-36-zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz.jsonl");
assert_eq!(extract_codex_session_id(path), None);
}
#[test]
fn too_short_suffix_is_rejected() {
let path = Path::new("rollout-2025-11-11T08-32-36-aaaa-bbbb-cccc-dddd.jsonl");
assert_eq!(extract_codex_session_id(path), None);
}
#[test]
fn non_rollout_filename_is_rejected() {
let path = Path::new("not-a-rollout-file.jsonl");
assert_eq!(extract_codex_session_id(path), None);
}
}