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, LineTailSource};
22
23enum DiscoveredKind {
30 LineTail {
31 source: LineTailSource,
32 session_id: Option<String>,
34 },
35 ChatGptExport,
36}
37
38struct DiscoveredFile {
40 path: PathBuf,
41 kind: DiscoveredKind,
42}
43
44pub struct MirrorConfig {
48 pub enabled: bool,
50 pub projects_dir: PathBuf,
54 pub codex_enabled: bool,
57 pub codex_sessions_dir: PathBuf,
61 pub chatgpt_enabled: bool,
64 pub chatgpt_exports_dir: PathBuf,
68 pub poll_interval: Duration,
70 pub backfill: bool,
73}
74
75const DEFAULT_MIRROR_POLL_SECS: u64 = 2;
78
79fn 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 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 #[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
208pub 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 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 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 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 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 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
352async 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 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
395fn 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
425fn 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
438fn 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
462fn 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
482fn 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
501fn 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 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 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 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}