Skip to main content

remem/ingest/
sessions.rs

1//! `remem ingest-sessions` — batch, incremental, idempotent ingestion of
2//! Claude Code / Codex session transcripts into `raw_messages` (issue #722).
3//!
4//! Discovery walks each scan root for `*.jsonl` files (skipping `subagents/`
5//! directories), a per-file cursor in `ingest_cursors` skips files whose
6//! mtime and size are unchanged, and each hit is drained through the existing
7//! `drain_transcript` path so the `raw_messages` UNIQUE constraint dedupes
8//! against the Stop-hook ingestion running concurrently.
9
10use 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
19/// A file whose mtime is within this many seconds of now is treated as an
20/// actively-appended session: a JSON parse failure on its last line is a
21/// partial tail, not a file failure, and the cursor does not advance.
22const ACTIVE_TAIL_WINDOW_SECS: i64 = 60;
23
24/// One scan root: a label recorded as `raw_messages.source_root` plus the
25/// directory to walk.
26#[derive(Debug, Clone)]
27pub struct ScanRoot {
28    pub label: String,
29    pub path: PathBuf,
30    /// Default local roots are optional because many users only have one host
31    /// installed. User-supplied `--root label=path` entries are required and
32    /// must not fail silently.
33    pub required: bool,
34}
35
36impl ScanRoot {
37    /// Parse a `--root label=path` argument.
38    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
64/// Default local scan roots: `~/.claude/projects` and `~/.codex/sessions`.
65/// Both are labeled `local` to match the hook-path `source_root` default.
66pub 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/// Machine-readable batch summary (product invariant 6).
86#[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    /// Skip files whose mtime is older than this lower bound (backfill bound;
108    /// window semantics on message timestamps belong to the query side).
109    pub since_epoch: Option<i64>,
110}
111
112/// Run one batch ingestion pass over the given scan roots (callers build the
113/// list from `default_scan_roots()` plus any `--root label=path` extras).
114pub 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
309/// Recursively collect `*.jsonl` files, excluding `subagents/` directories.
310fn 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                // drain_transcript_with_options already recorded the failure
436                // in raw_ingest_failures; keep the cursor behind so the file
437                // is retried on the next run.
438                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;