khive_pack_session/mirror/
service.rs1use 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
23struct DiscoveredFile {
26 path: PathBuf,
27 source: MirrorSource,
28 session_id: Option<String>,
30}
31
32pub struct MirrorConfig {
36 pub enabled: bool,
38 pub projects_dir: PathBuf,
42 pub codex_enabled: bool,
45 pub codex_sessions_dir: PathBuf,
49 pub poll_interval: Duration,
51 pub backfill: bool,
54}
55
56impl MirrorConfig {
57 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
106pub 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 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 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 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 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 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
235async 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 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
278fn 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
308fn 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
321fn 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
343fn 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 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 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 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}