Skip to main content

warden/
ingest.rs

1//! Resumable, idempotent ingest.
2//!
3//! Three properties matter, and each is bought by one mechanism:
4//!
5//! * **Cheap re-runs** — a cursor per source file records the byte offset
6//!   consumed. A file whose size and mtime are unchanged is not opened at all.
7//! * **Idempotence** — event ids are content-derived, so a replay produces
8//!   lines that are already in the store and are dropped before they are
9//!   written. Re-ingesting yields a byte-identical store.
10//! * **Crash safety** — a cursor is written only after a file has been read to
11//!   its last complete line. An interrupted run therefore replays from the last
12//!   committed offset, and the replay dedupes, so an interrupted-then-resumed
13//!   run and an uninterrupted one produce the same store.
14//!
15//! A partial trailing line (a writer caught mid-append) is never consumed: it
16//! is reported as skipped for this run and picked up once it is complete.
17
18use std::collections::{HashMap, HashSet};
19use std::fs::File;
20use std::io::{self, BufRead, BufReader, Seek, SeekFrom};
21use std::path::{Path, PathBuf};
22
23use chrono::Utc;
24
25use crate::adapters::{self, usage_key, Adapter, Parsed};
26use crate::cli::TimeWindow;
27use crate::config::{Config, TokenCounts};
28use crate::store::{
29    expand_tilde, text_hash, Event, IngestCursor, PromptRecord, ScanQuery, Scanner, StorePaths,
30    StoreWriter,
31};
32
33/// Scope for one ingest run. The window narrows *which files are opened*; it
34/// never drops events from a file that is read, because the store is supposed
35/// to be complete for every period it covers.
36#[derive(Debug, Clone)]
37pub struct IngestOptions {
38    /// Only consider source files modified within this window.
39    pub window: TimeWindow,
40    /// Only store events for this project. Because this does drop events, a
41    /// run with a project filter deliberately writes no cursors — a cursor must
42    /// only ever mean "this file is fully ingested".
43    pub project: Option<String>,
44}
45
46impl Default for IngestOptions {
47    fn default() -> Self {
48        Self {
49            window: TimeWindow::all(),
50            project: None,
51        }
52    }
53}
54
55/// What one adapter did, in the shape `warden ingest` prints.
56#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct AdapterIngest {
58    pub adapter: String,
59    /// Source root, when one could be resolved.
60    pub root: Option<PathBuf>,
61    /// Files that had unread bytes and were opened this run.
62    pub files_read: usize,
63    /// Files present under the root, whether or not they were opened.
64    pub files_seen: usize,
65    pub new_events: u64,
66    pub new_prompts: u64,
67    /// Lines the adapter could not parse. Never fatal.
68    pub skipped_unparseable: u64,
69    /// Source files skipped because their metadata could not be read (the file
70    /// vanished mid-run, or its mtime is unusable). Counted and reported rather
71    /// than passed over in silence: the next run will retry them.
72    pub unreadable_files: u64,
73    /// Lines replayed after an interruption that were already stored.
74    pub duplicates: u64,
75}
76
77/// Result of an ingest run.
78#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct IngestReport {
80    pub adapters: Vec<AdapterIngest>,
81    /// True when cursors were withheld because the run was filtered.
82    pub cursors_withheld: bool,
83}
84
85impl IngestReport {
86    pub fn new_events(&self) -> u64 {
87        self.adapters.iter().map(|a| a.new_events).sum()
88    }
89}
90
91/// Run every enabled, implemented adapter against the store.
92pub fn run(
93    config: &Config,
94    paths: &StorePaths,
95    options: &IngestOptions,
96) -> io::Result<IngestReport> {
97    let mut writer = StoreWriter::open(paths.clone())?;
98    let mut state = StoreState::load(paths)?;
99    let cursors = load_cursors(paths)?;
100
101    let mut report = IngestReport {
102        cursors_withheld: options.project.is_some(),
103        ..IngestReport::default()
104    };
105    for adapter in adapters::enabled(config) {
106        report.adapters.push(ingest_adapter(
107            adapter.as_ref(),
108            config,
109            options,
110            &cursors,
111            &mut state,
112            &mut writer,
113        )?);
114    }
115    Ok(report)
116}
117
118fn ingest_adapter(
119    adapter: &dyn Adapter,
120    config: &Config,
121    options: &IngestOptions,
122    cursors: &[IngestCursor],
123    state: &mut StoreState,
124    writer: &mut StoreWriter,
125) -> io::Result<AdapterIngest> {
126    let mut summary = AdapterIngest {
127        adapter: adapter.name().to_string(),
128        ..AdapterIngest::default()
129    };
130
131    let Some(root) = adapter
132        .root(config)
133        .map(|root| expand_tilde(&root))
134        .transpose()?
135    else {
136        return Ok(summary);
137    };
138    summary.root = Some(root.clone());
139    if !root.is_dir() {
140        return Ok(summary);
141    }
142
143    // Last record for a path wins (the log is append-only), so a single
144    // forward pass leaves the newest cursor per path. Indexing once beats
145    // re-scanning the whole log for every discovered file — the log grows by a
146    // line per file per run, so the linear form degrades every time it runs.
147    let mut latest: HashMap<&Path, &IngestCursor> = HashMap::new();
148    for cursor in cursors {
149        if cursor.adapter == adapter.name() {
150            latest.insert(Path::new(&cursor.path), cursor);
151        }
152    }
153
154    for path in adapter.discover(&root)? {
155        summary.files_seen += 1;
156        let Ok(meta) = std::fs::metadata(&path) else {
157            summary.unreadable_files += 1;
158            continue;
159        };
160        let Ok(mtime) = mtime_ms(&meta) else {
161            summary.unreadable_files += 1;
162            continue;
163        };
164        if !options.window.contains(mtime) {
165            continue;
166        }
167
168        let cursor = latest.get(path.as_path()).copied();
169        // A file that shrank was rotated or rewritten; re-read it from the
170        // start and let id dedup absorb what is already stored.
171        let start = match cursor {
172            Some(cursor) if cursor.offset <= meta.len() => cursor.offset,
173            _ => 0,
174        };
175        if start == meta.len() {
176            continue;
177        }
178
179        summary.files_read += 1;
180        let consumed = ingest_file(
181            adapter,
182            config,
183            options,
184            &path,
185            start,
186            state,
187            writer,
188            &mut summary,
189        )?;
190        if options.project.is_none() {
191            writer.append_cursor(&IngestCursor {
192                path: path.to_string_lossy().into_owned(),
193                mtime,
194                offset: consumed,
195                adapter: adapter.name().to_string(),
196                ts: Utc::now().timestamp_millis(),
197            })?;
198        }
199    }
200
201    Ok(summary)
202}
203
204/// Read one source file from `start`, returning the offset of the end of the
205/// last *complete* line consumed.
206#[allow(clippy::too_many_arguments)]
207fn ingest_file(
208    adapter: &dyn Adapter,
209    config: &Config,
210    options: &IngestOptions,
211    path: &Path,
212    start: u64,
213    state: &mut StoreState,
214    writer: &mut StoreWriter,
215    summary: &mut AdapterIngest,
216) -> io::Result<u64> {
217    // Read-only: the source tree is never opened for writing.
218    let mut file = File::open(path)?;
219    file.seek(SeekFrom::Start(start))?;
220    let mut reader = BufReader::new(file);
221
222    let mut offset = start;
223    let mut line = String::new();
224    loop {
225        line.clear();
226        let read = match reader.read_line(&mut line) {
227            Ok(0) => break,
228            Ok(read) => read,
229            // Invalid UTF-8 is corruption we cannot step over safely; stop
230            // here and let the next run retry from the committed offset.
231            Err(err) if err.kind() == io::ErrorKind::InvalidData => {
232                summary.skipped_unparseable += 1;
233                break;
234            }
235            Err(err) => return Err(err),
236        };
237        if !line.ends_with('\n') {
238            // A torn trailing line: the writer is mid-append. Skip it without
239            // consuming it, so it is ingested once it is complete.
240            summary.skipped_unparseable += 1;
241            break;
242        }
243
244        match adapter.parse_line(path, &line) {
245            Parsed::Skipped => {}
246            Parsed::Unparseable => summary.skipped_unparseable += 1,
247            Parsed::Record(record) => {
248                let mut event = record.event;
249                if !state.claim_event(&event.id) {
250                    summary.duplicates += 1;
251                } else if matches(options, &event) {
252                    apply_usage(&mut event, record.usage_key.as_deref(), state, config);
253                    writer.append_event(&event)?;
254                    summary.new_events += 1;
255                    if let Some(prompt) =
256                        prompt_record(&event, record.prompt_text.as_deref(), config)
257                    {
258                        writer.append_prompt(event.ts, &prompt)?;
259                        summary.new_prompts += 1;
260                    }
261                }
262            }
263        }
264        offset += read as u64;
265    }
266    Ok(offset)
267}
268
269fn matches(options: &IngestOptions, event: &Event) -> bool {
270    match &options.project {
271        Some(project) => event.project.as_deref() == Some(project.as_str()),
272        None => true,
273    }
274}
275
276/// Drop repeated per-request usage, then price whatever survives.
277fn apply_usage(event: &mut Event, key: Option<&str>, state: &mut StoreState, config: &Config) {
278    if let Some(key) = key {
279        if !state.claim_usage(key) {
280            event.input_tok = None;
281            event.output_tok = None;
282            event.cache_read_tok = None;
283            event.cache_write_tok = None;
284            return;
285        }
286    }
287    if let (true, Some(model)) = (event.has_usage(), event.model.as_deref()) {
288        // `None` when the model is unpriced — never a misleading 0.0.
289        event.cost_est = config.estimate_cost(
290            &event.provider,
291            model,
292            TokenCounts {
293                input: event.input_tok,
294                output: event.output_tok,
295                cache_read: event.cache_read_tok,
296                cache_write: event.cache_write_tok,
297            },
298        );
299    }
300}
301
302/// `text_hash` is always stored so duplicate detection survives with text off.
303fn prompt_record(event: &Event, text: Option<&str>, config: &Config) -> Option<PromptRecord> {
304    let text = text?;
305    Some(PromptRecord {
306        event_id: event.id.clone(),
307        text: config.general.index_prompt_text.then(|| text.to_string()),
308        text_hash: text_hash(text),
309    })
310}
311
312/// What the store already contains, so a replay writes nothing twice.
313struct StoreState {
314    event_ids: HashSet<String>,
315    usage_keys: HashSet<String>,
316}
317
318impl StoreState {
319    /// Rebuild from the store itself rather than from a side file: the JSONL is
320    /// the only authority, and this is what makes a resumed run agree with an
321    /// uninterrupted one.
322    fn load(paths: &StorePaths) -> io::Result<Self> {
323        let mut state = Self {
324            event_ids: HashSet::new(),
325            usage_keys: HashSet::new(),
326        };
327        let scanner = Scanner::new(paths.clone());
328        scanner.scan_with(&ScanQuery::new(TimeWindow::all()), |event| {
329            if let (Some(turn), true) = (event.turn_id.as_deref(), event.has_usage()) {
330                state
331                    .usage_keys
332                    .insert(usage_key(&event.agent, event.session_id.as_deref(), turn));
333            }
334            state.event_ids.insert(event.id);
335        })?;
336        Ok(state)
337    }
338
339    /// True when this id is new.
340    fn claim_event(&mut self, id: &str) -> bool {
341        self.event_ids.insert(id.to_string())
342    }
343
344    /// True when this request's usage has not been counted yet.
345    fn claim_usage(&mut self, key: &str) -> bool {
346        self.usage_keys.insert(key.to_string())
347    }
348}
349
350/// Read `state/ingest.jsonl`. Append-only, so later records for a path win;
351/// callers scan from the back.
352///
353/// A line that is *unparseable* is skipped — that is a torn append, and the
354/// record before it still stands. A line that cannot be **read** is an error:
355/// truncating the cursor list silently would replay every file from an older
356/// offset, and the run would look like a clean ingest that just found more
357/// events. A wrong answer with no diagnostic is worse than a failed run.
358pub fn load_cursors(paths: &StorePaths) -> io::Result<Vec<IngestCursor>> {
359    let path = paths.ingest_state_file();
360    let file = match File::open(&path) {
361        Ok(file) => file,
362        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
363        Err(err) => return Err(err),
364    };
365    let mut cursors = Vec::new();
366    for line in BufReader::new(file).lines() {
367        let line = line.map_err(|err| {
368            io::Error::new(
369                err.kind(),
370                format!(
371                    "reading ingest cursors from {}: {err}; refusing to continue from a truncated \
372                     cursor list, which would silently re-read sources from an older offset",
373                    path.display()
374                ),
375            )
376        })?;
377        if let Ok(cursor) = serde_json::from_str::<IngestCursor>(&line) {
378            cursors.push(cursor);
379        }
380    }
381    Ok(cursors)
382}
383
384/// A source file's mtime, in epoch milliseconds.
385///
386/// An unreadable or pre-epoch mtime is an error rather than `0`: `0` is a
387/// perfectly valid timestamp, so it would be written into a cursor and compared
388/// against `--since` windows as if it were the truth.
389fn mtime_ms(meta: &std::fs::Metadata) -> io::Result<i64> {
390    let modified = meta.modified()?;
391    let since = modified
392        .duration_since(std::time::UNIX_EPOCH)
393        .map_err(|err| {
394            io::Error::new(
395                io::ErrorKind::InvalidData,
396                format!("source file mtime precedes the unix epoch: {err}"),
397            )
398        })?;
399    i64::try_from(since.as_millis()).map_err(|_| {
400        io::Error::new(
401            io::ErrorKind::InvalidData,
402            "source file mtime does not fit in epoch milliseconds",
403        )
404    })
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use std::io::Write;
411
412    /// One assistant record, one sibling sharing its request, a user prompt, a
413    /// tool_use, a sidechain record, an unknown type and a malformed line.
414    fn fixture() -> String {
415        [
416            r#"{"type":"mode","mode":"normal"}"#.to_string(),
417            record("user", "u1", "10:00:00", None),
418            record("assistant", "u2", "10:00:01", Some("req_1")),
419            record("assistant", "u3", "10:00:02", Some("req_1")),
420            "{not json at all".to_string(),
421            record("assistant", "u4", "10:00:03", Some("req_2"))
422                .replace("\"isSidechain\":false", "\"isSidechain\":true"),
423        ]
424        .join("\n")
425            + "\n"
426    }
427
428    fn record(kind: &str, uuid: &str, time: &str, request: Option<&str>) -> String {
429        let request = request
430            .map(|id| format!(r#""requestId":"{id}","#))
431            .unwrap_or_default();
432        let message = if kind == "assistant" {
433            r#""message":{"model":"claude-sonnet-4-6","stop_reason":"tool_use",
434                "usage":{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":30,
435                         "cache_creation_input_tokens":40},
436                "content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}"#
437        } else {
438            r#""message":{"role":"user","content":"run the tests"}"#
439        };
440        format!(
441            r#"{{"type":"{kind}","uuid":"{uuid}","timestamp":"2026-08-01T{time}.000Z",
442              "sessionId":"s1","cwd":"/home/me/code/acme-api","isSidechain":false,{request}{message}}}"#
443        )
444        .replace('\n', " ")
445    }
446
447    struct Fixture {
448        _dir: tempfile::TempDir,
449        store: StorePaths,
450        source: PathBuf,
451        config: Config,
452    }
453
454    fn setup() -> Fixture {
455        setup_with_config(Config::default())
456    }
457
458    fn setup_with_config(mut config: Config) -> Fixture {
459        let dir = tempfile::tempdir().unwrap();
460        let logs = dir.path().join("logs/projects/-home-me-code-acme-api");
461        std::fs::create_dir_all(&logs).unwrap();
462        let source = logs.join("s1.jsonl");
463        std::fs::write(&source, fixture()).unwrap();
464
465        config.sources.insert(
466            "claude-code".into(),
467            crate::config::Source {
468                enabled: true,
469                path: Some(dir.path().join("logs/projects")),
470            },
471        );
472        Fixture {
473            store: StorePaths::new(dir.path().join("store")),
474            source,
475            config,
476            _dir: dir,
477        }
478    }
479
480    fn ingest(f: &Fixture) -> IngestReport {
481        run(&f.config, &f.store, &IngestOptions::default()).unwrap()
482    }
483
484    fn events(f: &Fixture) -> String {
485        std::fs::read_to_string(
486            f.store.event_partition(
487                crate::store::Partition::for_timestamp(1_785_924_000_000).unwrap(),
488            ),
489        )
490        .unwrap()
491    }
492
493    fn prompts(f: &Fixture) -> String {
494        std::fs::read_to_string(
495            f.store.prompt_partition(
496                crate::store::Partition::for_timestamp(1_785_924_000_000).unwrap(),
497            ),
498        )
499        .unwrap()
500    }
501
502    #[test]
503    fn ingests_every_supported_record_and_counts_the_bad_line() {
504        let f = setup();
505        let report = ingest(&f);
506        let summary = &report.adapters[0];
507        assert_eq!(summary.adapter, "claude-code");
508        assert_eq!(summary.files_read, 1);
509        // 1 user + 3 assistant; `mode` is skipped silently.
510        assert_eq!(summary.new_events, 4);
511        assert_eq!(summary.new_prompts, 1);
512        assert_eq!(summary.skipped_unparseable, 1);
513
514        let stored = events(&f);
515        assert_eq!(stored.lines().count(), 4);
516        assert!(stored.contains("\"tool_name\":\"Read\""));
517        assert!(stored.contains("\"is_sidechain\":true"));
518        assert!(!stored.contains("duration_ms"), "never invented");
519        assert!(prompts(&f).contains("\"text\":\"run the tests\""));
520    }
521
522    #[test]
523    fn repeated_request_usage_is_counted_once() {
524        let f = setup();
525        ingest(&f);
526        let counted = events(&f)
527            .lines()
528            .filter(|line| line.contains("\"input_tok\":10"))
529            .count();
530        // req_1 appears on two assistant records; req_2 on one.
531        assert_eq!(counted, 2);
532    }
533
534    #[test]
535    fn second_ingest_is_a_byte_identical_no_op() {
536        let f = setup();
537        ingest(&f);
538        let before = events(&f);
539        let report = ingest(&f);
540        assert_eq!(report.new_events(), 0);
541        assert_eq!(
542            report.adapters[0].files_read, 0,
543            "unchanged file not reopened"
544        );
545        assert_eq!(events(&f), before);
546    }
547
548    #[test]
549    fn interrupted_run_replays_to_an_identical_store() {
550        // Uninterrupted.
551        let full = setup();
552        ingest(&full);
553        let expected = events(&full);
554
555        // Interrupted: only the first half of the file existed, and no cursor
556        // was committed for the rest.
557        let partial = setup();
558        let all = std::fs::read_to_string(&partial.source).unwrap();
559        let cut = all.match_indices('\n').nth(2).unwrap().0 + 1;
560        std::fs::write(&partial.source, &all[..cut]).unwrap();
561        ingest(&partial);
562        std::fs::write(&partial.source, &all).unwrap();
563        ingest(&partial);
564
565        assert_eq!(events(&partial), expected);
566        assert_eq!(prompts(&partial), prompts(&full));
567    }
568
569    #[test]
570    fn replaying_from_a_stale_offset_writes_nothing_twice() {
571        let f = setup();
572        ingest(&f);
573        let before = events(&f);
574        // Simulate a crash after events were written but before the cursor was:
575        // rewind the committed offset to zero.
576        let mut state = std::fs::OpenOptions::new()
577            .append(true)
578            .open(f.store.ingest_state_file())
579            .unwrap();
580        let mut cursor = load_cursors(&f.store).unwrap().pop().unwrap();
581        cursor.offset = 0;
582        writeln!(state, "{}", serde_json::to_string(&cursor).unwrap()).unwrap();
583        drop(state);
584
585        let report = ingest(&f);
586        assert_eq!(report.new_events(), 0);
587        assert!(report.adapters[0].duplicates > 0);
588        assert_eq!(events(&f), before);
589    }
590
591    #[test]
592    fn a_torn_trailing_line_is_skipped_counted_and_retried() {
593        let f = setup();
594        let all = std::fs::read_to_string(&f.source).unwrap();
595        let cut = all.rfind('\n').unwrap() + 1;
596        let torn = format!("{}{}", &all[..cut], r#"{"type":"assistant","uuid":"u9""#);
597        std::fs::write(&f.source, &torn).unwrap();
598
599        let report = ingest(&f);
600        // The malformed line plus the torn trailing one.
601        assert_eq!(report.adapters[0].skipped_unparseable, 2);
602        let stored = events(&f);
603        assert!(!stored.contains("\"u9\""));
604
605        // Completing the line ingests it without duplicating anything before it.
606        std::fs::write(&f.source, all).unwrap();
607        ingest(&f);
608        assert_eq!(events(&f).lines().count(), 4);
609    }
610
611    #[test]
612    fn index_prompt_text_false_stores_only_the_hash() {
613        let mut config = Config::default();
614        config.general.index_prompt_text = false;
615        let f = setup_with_config(config);
616        ingest(&f);
617        let stored = prompts(&f);
618        assert!(stored.contains("\"text_hash\":"));
619        assert!(!stored.contains("run the tests"), "{stored}");
620    }
621
622    /// An unparseable cursor line is a torn append: skip it, keep the rest.
623    #[test]
624    fn a_torn_cursor_line_is_skipped_but_the_others_still_load() {
625        let f = setup();
626        ingest(&f);
627        let mut state = std::fs::OpenOptions::new()
628            .append(true)
629            .open(f.store.ingest_state_file())
630            .unwrap();
631        write!(state, "{{\"path\":\"/x\",\"mtime\"").unwrap();
632        drop(state);
633
634        assert_eq!(load_cursors(&f.store).unwrap().len(), 1);
635    }
636
637    /// An *unreadable* cursor line is not: silently truncating the list would
638    /// replay every source from an older offset with no diagnostic.
639    #[test]
640    fn an_unreadable_cursor_line_is_an_error_not_a_silent_truncation() {
641        let f = setup();
642        ingest(&f);
643        let committed = load_cursors(&f.store).unwrap();
644        assert_eq!(committed.len(), 1, "a cursor was written to truncate");
645
646        // Invalid UTF-8: `BufRead::lines` yields Err, not a short line.
647        let mut state = std::fs::OpenOptions::new()
648            .append(true)
649            .open(f.store.ingest_state_file())
650            .unwrap();
651        state.write_all(&[0xff, 0xfe, b'\n']).unwrap();
652        drop(state);
653
654        let err = load_cursors(&f.store).unwrap_err();
655        let msg = err.to_string();
656        assert!(msg.contains("ingest cursors"), "{msg}");
657        assert!(msg.contains("older offset"), "{msg}");
658
659        // And the run that would have replayed fails loudly instead.
660        assert!(run(&f.config, &f.store, &IngestOptions::default()).is_err());
661    }
662
663    #[test]
664    fn a_usable_mtime_is_required_rather_than_defaulted_to_zero() {
665        let meta = std::fs::metadata(&setup().source).unwrap();
666        let mtime = mtime_ms(&meta).expect("a real file has a readable mtime");
667        assert!(mtime > 1_700_000_000_000, "got {mtime}");
668    }
669
670    #[test]
671    fn a_project_filter_scopes_the_run_and_withholds_cursors() {
672        let f = setup();
673        let options = IngestOptions {
674            project: Some("other".into()),
675            ..IngestOptions::default()
676        };
677        let report = run(&f.config, &f.store, &options).unwrap();
678        assert_eq!(report.new_events(), 0);
679        assert!(report.cursors_withheld);
680        assert!(load_cursors(&f.store).unwrap().is_empty());
681    }
682}