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, MirrorSource};
struct DiscoveredFile {
path: PathBuf,
source: MirrorSource,
session_id: Option<String>,
}
pub struct MirrorConfig {
pub enabled: bool,
pub projects_dir: PathBuf,
pub codex_enabled: bool,
pub codex_sessions_dir: PathBuf,
pub poll_interval: Duration,
pub backfill: bool,
}
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 poll_secs = std::env::var("KHIVE_MIRROR_POLL_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(2);
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,
poll_interval: Duration::from_secs(poll_secs),
backfill,
}
}
}
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,
source: MirrorSource::ClaudeCode,
session_id: None,
});
}
}
if config.codex_enabled {
for item in scan_codex_jsonl_files(&config.codex_sessions_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;
}
match ingest::mirror_file(
&runtime,
&item.path,
offset,
item.source,
item.session_id.as_deref(),
)
.await
{
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(),
source = ?item.source,
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,
source: MirrorSource::Codex,
session_id: Some(session_id),
});
}
}
}
}
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);
}
}