1use std::collections::{BTreeMap, BTreeSet};
11use std::path::{Path, PathBuf};
12
13use anyhow::{bail, Result};
14use rusqlite::{params, Connection, OptionalExtension};
15use serde::Serialize;
16
17use crate::memory::raw_archive::{self, TranscriptDrainOptions, SOURCE_ROOT_LOCAL};
18
19const ACTIVE_TAIL_WINDOW_SECS: i64 = 60;
23
24#[derive(Debug, Clone)]
27pub struct ScanRoot {
28 pub label: String,
29 pub path: PathBuf,
30 pub required: bool,
34}
35
36impl ScanRoot {
37 pub fn parse(spec: &str) -> Result<Self> {
39 let Some((label, path)) = spec.split_once('=') else {
40 bail!("invalid --root {spec:?}: expected label=path");
41 };
42 let label = label.trim();
43 let path = path.trim();
44 if label.is_empty() || path.is_empty() {
45 bail!("invalid --root {spec:?}: label and path must be non-empty");
46 }
47 Ok(Self {
48 label: label.to_string(),
49 path: PathBuf::from(shellexpand_home(path)),
50 required: true,
51 })
52 }
53}
54
55fn shellexpand_home(path: &str) -> String {
56 if let Some(rest) = path.strip_prefix("~/") {
57 if let Some(home) = dirs::home_dir() {
58 return home.join(rest).to_string_lossy().to_string();
59 }
60 }
61 path.to_string()
62}
63
64pub fn default_scan_roots() -> Vec<ScanRoot> {
67 let Some(home) = dirs::home_dir() else {
68 crate::log::warn("ingest-sessions", "home directory unavailable");
69 return Vec::new();
70 };
71 vec![
72 ScanRoot {
73 label: SOURCE_ROOT_LOCAL.to_string(),
74 path: home.join(".claude").join("projects"),
75 required: false,
76 },
77 ScanRoot {
78 label: SOURCE_ROOT_LOCAL.to_string(),
79 path: home.join(".codex").join("sessions"),
80 required: false,
81 },
82 ]
83}
84
85#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
87pub struct IngestSummary {
88 pub scanned: usize,
89 pub skipped: usize,
90 pub ingested_messages: usize,
91 pub failed_files: usize,
92 pub partial_files: usize,
93}
94
95impl IngestSummary {
96 pub fn exit_code(&self) -> i32 {
97 if self.failed_files > 0 {
98 1
99 } else {
100 0
101 }
102 }
103}
104
105#[derive(Debug, Clone, Default)]
106pub struct IngestOptions {
107 pub since_epoch: Option<i64>,
110}
111
112pub fn run_ingest_sessions(
115 conn: &Connection,
116 roots: &[ScanRoot],
117 options: &IngestOptions,
118) -> Result<IngestSummary> {
119 let mut summary = IngestSummary::default();
120 let now = chrono::Utc::now().timestamp();
121 let mut project_cache = BTreeMap::new();
122
123 let mut discovered = Vec::new();
124 for root in roots {
125 let (files, discovery_failures) = discover_transcript_files(root);
126 for failure in discovery_failures {
127 summary.failed_files += 1;
128 crate::log::error("ingest-sessions", &failure);
129 }
130 for file in files {
131 summary.scanned += 1;
132 let plan = match super::session_identity::probe_with_project_cache(
133 &root.label,
134 &root.path,
135 &file,
136 None,
137 &mut project_cache,
138 ) {
139 Ok(plan) => plan,
140 Err(error) => {
141 summary.failed_files += 1;
142 crate::log::error(
143 "ingest-sessions",
144 &format!("identity probe {} failed: {error}", file.display()),
145 );
146 continue;
147 }
148 };
149 let mtime_epoch = plan.observed_mtime_ns / 1_000_000_000;
150 let phase_b_eligible = !options.since_epoch.is_some_and(|since| mtime_epoch < since);
151 discovered.push((root.clone(), plan, phase_b_eligible));
152 }
153 }
154 if summary.failed_files > 0 {
155 crate::log::error(
156 "ingest-sessions",
157 "Phase A discovery/probe was incomplete; Phase B mutation is blocked",
158 );
159 return Ok(summary);
160 }
161 conn.execute_batch("SAVEPOINT gh871_identity_phase_a")?;
162 let phase_a =
163 (|| -> Result<Vec<(ScanRoot, super::session_identity::TranscriptPlan, i64, bool)>> {
164 let mut prepared = Vec::with_capacity(discovered.len());
165 let mut groups = BTreeSet::new();
166 for (root, plan, phase_b_eligible) in discovered {
167 let identity_id = super::session_identity::upsert_claim(conn, &plan, now)?;
168 groups.insert((plan.source_root.clone(), plan.fallback_session_id.clone()));
169 prepared.push((root, plan, identity_id, phase_b_eligible));
170 }
171 for (source_root, fallback_session_id) in groups {
172 super::session_identity::resolve_fallback_group(
173 conn,
174 &source_root,
175 &fallback_session_id,
176 )?;
177 }
178 Ok(prepared)
179 })();
180 let prepared = match phase_a {
181 Ok(prepared) => {
182 conn.execute_batch("RELEASE gh871_identity_phase_a")?;
183 prepared
184 }
185 Err(error) => {
186 conn.execute_batch(
187 "ROLLBACK TO gh871_identity_phase_a; RELEASE gh871_identity_phase_a",
188 )?;
189 return Err(error.context("persist complete transcript identity claim set"));
190 }
191 };
192
193 let mut prepared_groups = BTreeMap::new();
194 for prepared_file in prepared {
195 let key = (
196 prepared_file.1.source_root.clone(),
197 prepared_file.1.fallback_session_id.clone(),
198 );
199 prepared_groups
200 .entry(key)
201 .or_insert_with(Vec::new)
202 .push(prepared_file);
203 }
204 for ((source_root, fallback_session_id), group) in prepared_groups {
205 conn.execute_batch("SAVEPOINT gh871_identity_phase_b_group")?;
206 let ingested_before = summary.ingested_messages;
207 let partial_before = summary.partial_files;
208 let mut identity_conflict = false;
209 for (root, plan, identity_id, phase_b_eligible) in &group {
210 if !phase_b_eligible {
211 let indexed = super::session_identity::index_events(
212 &plan.transcript_path,
213 u64::try_from(plan.observed_size_bytes).unwrap_or(u64::MAX),
214 )
215 .and_then(|index| {
216 super::session_identity::record_since_skipped_event_index(
217 conn,
218 *identity_id,
219 index,
220 now,
221 )
222 });
223 match indexed {
224 Ok(()) => summary.skipped += 1,
225 Err(error) => {
226 summary.failed_files += 1;
227 crate::log::error(
228 "ingest-sessions",
229 &format!("index skipped {} failed: {error}", plan.path.display()),
230 );
231 }
232 }
233 continue;
234 }
235 conn.execute_batch("SAVEPOINT gh871_identity_phase_b_file")?;
236 let inserted_before = summary.ingested_messages;
237 let result = ingest_prepared_file(conn, root, plan, *identity_id, now, &mut summary);
238 match result {
239 PreparedFileResult::Commit => {
240 conn.execute_batch("RELEASE gh871_identity_phase_b_file")?;
241 }
242 PreparedFileResult::Rollback {
243 identity_conflict: file_identity_conflict,
244 } => {
245 conn.execute_batch(
246 "ROLLBACK TO gh871_identity_phase_b_file;
247 RELEASE gh871_identity_phase_b_file",
248 )?;
249 summary.ingested_messages = inserted_before;
250 if file_identity_conflict {
251 identity_conflict = true;
252 break;
253 }
254 }
255 }
256 }
257 if identity_conflict {
258 conn.execute_batch(
259 "ROLLBACK TO gh871_identity_phase_b_group;
260 RELEASE gh871_identity_phase_b_group",
261 )?;
262 summary.ingested_messages = ingested_before;
263 summary.partial_files = partial_before;
264 super::session_identity::mark_fallback_group_conflict(
265 conn,
266 &source_root,
267 &fallback_session_id,
268 "stable_occurrence_mismatch",
269 )?;
270 } else {
271 conn.execute_batch("RELEASE gh871_identity_phase_b_group")?;
272 }
273 }
274
275 crate::log::info(
276 "ingest-sessions",
277 &format!(
278 "batch done scanned={} skipped={} ingested_messages={} failed_files={} partial_files={}",
279 summary.scanned,
280 summary.skipped,
281 summary.ingested_messages,
282 summary.failed_files,
283 summary.partial_files
284 ),
285 );
286 Ok(summary)
287}
288
289pub(crate) fn discover_transcript_files(root: &ScanRoot) -> (Vec<PathBuf>, Vec<String>) {
290 if !root.path.is_dir() {
291 let failures = if root.required {
292 vec![format!(
293 "required scan root {}={} is missing or not a directory",
294 root.label,
295 root.path.display()
296 )]
297 } else {
298 Vec::new()
299 };
300 return (Vec::new(), failures);
301 }
302 let mut files = Vec::new();
303 let mut failures = Vec::new();
304 collect_jsonl_files(&root.path, &mut files, &mut failures);
305 files.sort();
306 (files, failures)
307}
308
309fn collect_jsonl_files(dir: &Path, out: &mut Vec<PathBuf>, failures: &mut Vec<String>) {
311 let entries = match std::fs::read_dir(dir) {
312 Ok(entries) => entries,
313 Err(error) => {
314 failures.push(format!("read scan dir {} failed: {}", dir.display(), error));
315 return;
316 }
317 };
318 for entry in entries {
319 let entry = match entry {
320 Ok(entry) => entry,
321 Err(error) => {
322 failures.push(format!(
323 "read scan dir entry in {} failed: {}",
324 dir.display(),
325 error
326 ));
327 continue;
328 }
329 };
330 let path = entry.path();
331 let file_type = match entry.file_type() {
332 Ok(file_type) => file_type,
333 Err(error) => {
334 failures.push(format!("stat {} failed: {}", path.display(), error));
335 continue;
336 }
337 };
338 if file_type.is_dir() {
339 if entry.file_name() == "subagents" {
340 continue;
341 }
342 collect_jsonl_files(&path, out, failures);
343 } else if file_type.is_file() && path.extension().is_some_and(|ext| ext == "jsonl") {
344 out.push(path);
345 }
346 }
347}
348
349enum PreparedFileResult {
350 Commit,
351 Rollback { identity_conflict: bool },
352}
353
354fn ingest_prepared_file(
355 conn: &Connection,
356 root: &ScanRoot,
357 plan: &super::session_identity::TranscriptPlan,
358 identity_id: i64,
359 now: i64,
360 summary: &mut IngestSummary,
361) -> PreparedFileResult {
362 let identity = match super::session_identity::load(conn, identity_id) {
363 Ok(identity) => identity,
364 Err(error) => {
365 summary.failed_files += 1;
366 crate::log::error(
367 "ingest-sessions",
368 &format!("load identity {} failed: {error}", plan.path.display()),
369 );
370 return PreparedFileResult::Commit;
371 }
372 };
373 if identity.status == "conflict" {
374 summary.failed_files += 1;
375 crate::log::error(
376 "ingest-sessions",
377 &format!(
378 "identity conflict for transcript {}; raw rows remain unchanged",
379 plan.path.display()
380 ),
381 );
382 return PreparedFileResult::Commit;
383 }
384 let mtime_epoch = plan.observed_mtime_ns / 1_000_000_000;
385 let size_bytes = plan.observed_size_bytes;
386 match cursor_unchanged(conn, root, &plan.path, mtime_epoch, size_bytes) {
387 Ok(true) if identity.contract_version >= 1 => {
388 summary.skipped += 1;
389 return PreparedFileResult::Commit;
390 }
391 Ok(true) | Ok(false) => {}
392 Err(error) => {
393 summary.failed_files += 1;
394 crate::log::error(
395 "ingest-sessions",
396 &format!("cursor lookup {} failed: {}", plan.path.display(), error),
397 );
398 return PreparedFileResult::Commit;
399 }
400 }
401
402 let event_index = match super::session_identity::index_events(
403 &plan.transcript_path,
404 u64::try_from(size_bytes).unwrap_or(u64::MAX),
405 ) {
406 Ok(index) => index,
407 Err(error) => {
408 summary.failed_files += 1;
409 crate::log::error(
410 "ingest-sessions",
411 &format!("index {} failed: {error}", plan.path.display()),
412 );
413 return PreparedFileResult::Commit;
414 }
415 };
416 let drain_options = TranscriptDrainOptions {
417 source_root: &root.label,
418 tolerate_partial_tail: now - mtime_epoch <= ACTIVE_TAIL_WINDOW_SECS,
419 transcript_identity_id: Some(identity.id),
420 };
421
422 match raw_archive::drain_transcript_with_capture_limit(
423 conn,
424 &plan.transcript_path,
425 &identity.canonical_session_id,
426 &identity.project,
427 plan.branch.as_deref(),
428 plan.cwd.as_deref(),
429 &drain_options,
430 Some(u64::try_from(size_bytes).unwrap_or(u64::MAX)),
431 ) {
432 Ok(report) => {
433 summary.ingested_messages += report.inserted;
434 if report.has_failures() {
435 summary.failed_files += 1;
439 crate::log::error(
440 "ingest-sessions",
441 &format!(
442 "file {} failed: kind={} parse_errors={} insert_errors={} read_error={}",
443 plan.path.display(),
444 report.failure_kind().unwrap_or("unknown"),
445 report.parse_errors,
446 report.insert_errors,
447 report.read_error.is_some()
448 ),
449 );
450 if report.identity_conflicts > 0 {
451 return PreparedFileResult::Rollback {
452 identity_conflict: true,
453 };
454 }
455 } else if report.partial_tail {
456 summary.partial_files += 1;
457 } else {
458 let completion = (|| -> Result<super::session_identity::RekeyReport> {
459 conn.execute_batch("SAVEPOINT gh871_identity_complete")?;
460 let rekey = super::session_identity::rekey_legacy_rows(conn, &identity)?;
461 super::session_identity::mark_complete(conn, identity.id, event_index, now)?;
462 advance_cursor(conn, root, &plan.path, mtime_epoch, size_bytes, now)?;
463 conn.execute_batch("RELEASE gh871_identity_complete")?;
464 Ok(rekey)
465 })();
466 match completion {
467 Ok(rekey) => {
468 summary.ingested_messages =
469 summary.ingested_messages.saturating_sub(rekey.merged);
470 }
471 Err(error) => {
472 if let Err(rollback_error) = conn.execute_batch(
473 "ROLLBACK TO gh871_identity_complete; RELEASE gh871_identity_complete",
474 ) {
475 crate::log::error(
476 "ingest-sessions",
477 &format!(
478 "identity completion rollback {} failed: {rollback_error}",
479 plan.path.display()
480 ),
481 );
482 }
483 summary.failed_files += 1;
484 crate::log::error(
485 "ingest-sessions",
486 &format!(
487 "identity completion {} failed: {error}",
488 plan.path.display()
489 ),
490 );
491 return PreparedFileResult::Rollback {
492 identity_conflict: error
493 .downcast_ref::<crate::memory::raw_occurrence::RawIdentityConflict>(
494 )
495 .is_some(),
496 };
497 }
498 }
499 }
500 }
501 Err(error) => {
502 summary.failed_files += 1;
503 crate::log::error(
504 "ingest-sessions",
505 &format!("drain {} failed: {}", plan.path.display(), error),
506 );
507 }
508 }
509 PreparedFileResult::Commit
510}
511
512fn cursor_unchanged(
513 conn: &Connection,
514 root: &ScanRoot,
515 file: &Path,
516 mtime_epoch: i64,
517 size_bytes: i64,
518) -> Result<bool> {
519 let key = cursor_key(root, file);
520 let row: Option<(i64, i64)> = conn
521 .query_row(
522 "SELECT mtime_epoch, size_bytes FROM ingest_cursors WHERE file_path = ?1",
523 params![key],
524 |row| Ok((row.get(0)?, row.get(1)?)),
525 )
526 .optional()?;
527 Ok(row == Some((mtime_epoch, size_bytes)))
528}
529
530pub(crate) fn cursor_matches_identity(
531 conn: &Connection,
532 root: &ScanRoot,
533 file: &Path,
534 observed_mtime_ns: i64,
535 observed_size_bytes: i64,
536) -> Result<bool> {
537 cursor_unchanged(
538 conn,
539 root,
540 file,
541 observed_mtime_ns / 1_000_000_000,
542 observed_size_bytes,
543 )
544}
545
546fn advance_cursor(
547 conn: &Connection,
548 root: &ScanRoot,
549 file: &Path,
550 mtime_epoch: i64,
551 size_bytes: i64,
552 now: i64,
553) -> Result<()> {
554 let key = cursor_key(root, file);
555 conn.execute(
556 "INSERT INTO ingest_cursors (file_path, mtime_epoch, size_bytes, last_ingested_at)
557 VALUES (?1, ?2, ?3, ?4)
558 ON CONFLICT(file_path) DO UPDATE SET
559 mtime_epoch = excluded.mtime_epoch,
560 size_bytes = excluded.size_bytes,
561 last_ingested_at = excluded.last_ingested_at",
562 params![key, mtime_epoch, size_bytes, now],
563 )?;
564 Ok(())
565}
566
567fn cursor_key(root: &ScanRoot, file: &Path) -> String {
568 format!("{}\0{}", root.label, file.to_string_lossy())
569}
570
571#[cfg(test)]
572#[path = "sessions/tests.rs"]
573mod tests;