Skip to main content

context_harness/
ingest.rs

1//! Ingestion pipeline orchestration.
2//!
3//! Coordinates the full sync flow: connector → normalization → chunking →
4//! embedding → storage. Supports incremental sync via checkpoints and
5//! inline embedding (non-fatal on failure).
6//!
7//! # Sync Pipeline
8//!
9//! The [`run_sync`] function implements the following pipeline:
10//!
11//! 1. **Resolve targets** — expands the connector argument into one or more
12//!    `(source_label, connector_type, instance_name)` tuples. Supports:
13//!    - `"git:platform"` — a single named instance
14//!    - `"git"` — all instances of a type
15//!    - `"all"` — every configured connector
16//! 2. **Scan (parallel)** — dispatches to the appropriate connector for each
17//!    target, running scans concurrently via [`tokio::task::JoinSet`].
18//! 3. **Filter** — applies checkpoint, `--since`, `--until`, and `--limit`
19//!    filters to each connector's items.
20//! 4. **Upsert documents** — inserts or updates each item in the `documents`
21//!    table, computing a SHA-256 deduplication hash.
22//! 5. **Replace chunks** — deletes old chunks (and their embeddings/FTS entries)
23//!    for the document, then inserts fresh chunks.
24//! 6. **Inline embed** — if embeddings are enabled, embeds new chunks
25//!    immediately (non-fatal: failures are logged but do not abort the sync).
26//! 7. **Update checkpoint** — persists the latest `updated_at` timestamp
27//!    so the next incremental sync can skip unchanged items.
28//!
29//! # Deduplication
30//!
31//! Each document is identified by `(source, source_id)`. If a document with
32//! the same composite key already exists, it is updated via an `ON CONFLICT`
33//! upsert. The `dedup_hash` field is a SHA-256 digest of source + source_id +
34//! updated_at + body, enabling downstream consumers to detect changes.
35//!
36//! # Checkpointing
37//!
38//! Checkpoints are stored in the `checkpoints` table as `(source, cursor)`
39//! pairs. The cursor is the maximum `updated_at` timestamp seen during the
40//! sync. On subsequent runs, only items newer than the checkpoint are processed.
41//!
42//! # Multi-Instance Connectors
43//!
44//! All connector types support named instances. Documents are tagged with
45//! `source = "type:name"` (e.g. `"git:platform"`, `"filesystem:docs"`).
46//! When syncing a type (e.g. `ctx sync git`), all instances of that type
47//! are scanned in parallel.
48
49use crate::app_store::{AppStore, SqliteAppStore};
50use crate::chunk::chunk_text;
51use crate::config::Config;
52use crate::document_ingestor::{DocumentIngestOutcome, DocumentIngestor};
53use crate::models::SourceItem;
54use crate::progress::{SyncProgressEvent, SyncProgressReporter};
55use crate::traits::{Connector, ConnectorRegistry};
56use anyhow::{bail, Result};
57use chrono::NaiveDate;
58
59/// Resolve a connector argument into a filtered list of connectors to scan.
60///
61/// The `registry` contains all connectors (built-in + custom). This function
62/// filters them based on the user's connector argument.
63///
64/// # Supported Formats
65///
66/// | Input | Meaning |
67/// |-------|---------|
68/// | `"all"` | Every registered connector |
69/// | `"git"` | All connectors of type `"git"` |
70/// | `"filesystem"` | All connectors of type `"filesystem"` |
71/// | `"s3"` | All connectors of type `"s3"` |
72/// | `"script"` | All connectors of type `"script"` |
73/// | `"custom"` | All connectors of type `"custom"` |
74/// | `"git:platform"` | Specific named instance |
75/// | `"custom:myconn"` | Specific named instance |
76fn resolve_connectors<'a>(
77    registry: &'a ConnectorRegistry,
78    connector_arg: &str,
79) -> Result<Vec<&'a dyn Connector>> {
80    match connector_arg {
81        "all" => {
82            let all = registry.connectors();
83            if all.is_empty() {
84                bail!("No connectors configured. Add connector sections to your config.");
85            }
86            Ok(all.iter().map(|c| c.as_ref()).collect())
87        }
88        // Type-level filter: "git", "filesystem", "s3", "script", "custom"
89        conn_type if matches!(conn_type, "filesystem" | "git" | "s3" | "script" | "custom") => {
90            let matched = registry.connectors_by_type(conn_type);
91            if matched.is_empty() {
92                bail!("No {} connectors configured.", conn_type);
93            }
94            Ok(matched)
95        }
96        // Instance-level filter: "git:platform", "custom:mything"
97        other => {
98            if let Some((conn_type, name)) = other.split_once(':') {
99                let conn = registry.find(conn_type, name).ok_or_else(|| {
100                    let available: Vec<String> = registry
101                        .connectors_by_type(conn_type)
102                        .iter()
103                        .map(|c| c.name().to_string())
104                        .collect();
105                    anyhow::anyhow!(
106                        "No {} connector '{}'. Available: {}",
107                        conn_type,
108                        name,
109                        if available.is_empty() {
110                            "(none)".to_string()
111                        } else {
112                            available.join(", ")
113                        }
114                    )
115                })?;
116                Ok(vec![conn])
117            } else {
118                bail!(
119                    "Unknown connector: '{}'. Use: all, filesystem, git, s3, script, custom, or type:name",
120                    other
121                );
122            }
123        }
124    }
125}
126
127/// Runs the full ingestion pipeline for the specified connector(s).
128///
129/// This is the entry point for `ctx sync <connector>`. It builds a
130/// [`ConnectorRegistry`] from the config and runs the pipeline.
131///
132/// # Arguments
133///
134/// - `config` — application configuration.
135/// - `connector` — the connector specifier: `"all"`, a type name (`"git"`),
136///   or a specific instance (`"git:platform"`).
137/// - `full` — if `true`, ignores existing checkpoints and reprocesses all items.
138/// - `dry_run` — if `true`, scans and counts items without writing to the database.
139/// - `since` — optional `YYYY-MM-DD` date; only items updated on or after this date are processed.
140/// - `until` — optional `YYYY-MM-DD` date; only items updated on or before this date are processed.
141/// - `limit` — optional maximum number of items to process (per connector instance).
142/// - `progress` — optional progress reporter; when provided, progress is emitted on stderr.
143///
144/// # Errors
145///
146/// Returns an error if:
147/// - The specified connector is unknown or not configured.
148/// - A connector fails to scan (e.g., network error).
149/// - A database operation fails.
150#[allow(clippy::too_many_arguments)]
151pub async fn run_sync(
152    config: &Config,
153    connector: &str,
154    full: bool,
155    dry_run: bool,
156    since: Option<String>,
157    until: Option<String>,
158    limit: Option<usize>,
159    progress: Option<&dyn SyncProgressReporter>,
160) -> Result<()> {
161    let registry = ConnectorRegistry::from_config(config);
162    run_sync_with_registry(
163        config, connector, full, dry_run, since, until, limit, &registry, progress,
164    )
165    .await
166}
167
168/// Runs the ingestion pipeline with additional custom connectors.
169///
170/// Like [`run_sync`], but accepts extra connectors to merge with
171/// the built-in ones from config. This is the entry point for custom
172/// binaries that register `Connector` trait implementations.
173///
174/// The `extra_connectors` are combined with the built-in connectors
175/// resolved from the config file.
176#[allow(clippy::too_many_arguments, dead_code)]
177pub async fn run_sync_with_extensions(
178    config: &Config,
179    connector: &str,
180    full: bool,
181    dry_run: bool,
182    since: Option<String>,
183    until: Option<String>,
184    limit: Option<usize>,
185    extra_connectors: &ConnectorRegistry,
186) -> Result<()> {
187    // Build combined connector list from config + extras
188    let built_in = ConnectorRegistry::from_config(config);
189
190    // Resolve from built-in registry (ignore error if extras might match)
191    let mut resolved: Vec<&dyn Connector> = match resolve_connectors(&built_in, connector) {
192        Ok(r) => r,
193        Err(_) if !extra_connectors.is_empty() => Vec::new(),
194        Err(e) => return Err(e),
195    };
196
197    // Also resolve from extras
198    if let Ok(extras) = resolve_connectors(extra_connectors, connector) {
199        resolved.extend(extras);
200    }
201
202    if resolved.is_empty() {
203        bail!("No connectors matched '{}'.", connector);
204    }
205
206    run_connectors(config, &resolved, full, dry_run, since, until, limit, None).await
207}
208
209/// Runs the ingestion pipeline with a pre-built [`ConnectorRegistry`].
210///
211/// This is the unified entry point. All connectors in the registry are
212/// trait objects — built-in, Lua, and custom all go through the same path.
213#[allow(clippy::too_many_arguments)]
214pub async fn run_sync_with_registry(
215    config: &Config,
216    connector: &str,
217    full: bool,
218    dry_run: bool,
219    since: Option<String>,
220    until: Option<String>,
221    limit: Option<usize>,
222    registry: &ConnectorRegistry,
223    progress: Option<&dyn SyncProgressReporter>,
224) -> Result<()> {
225    let connectors = resolve_connectors(registry, connector)?;
226    run_connectors(
227        config,
228        &connectors,
229        full,
230        dry_run,
231        since,
232        until,
233        limit,
234        progress,
235    )
236    .await
237}
238
239/// Report progress every N items during ingest (avoids flooding stderr).
240const INGEST_PROGRESS_INTERVAL: u64 = 10;
241
242/// Core sync engine: scans connectors sequentially, ingests items.
243///
244/// Scans are run sequentially since connectors hold references. Each
245/// connector's items flow through the standard checkpoint → filter → upsert →
246/// chunk → embed pipeline.
247#[allow(clippy::too_many_arguments)]
248async fn run_connectors(
249    config: &Config,
250    connectors: &[&dyn Connector],
251    full: bool,
252    dry_run: bool,
253    since: Option<String>,
254    until: Option<String>,
255    limit: Option<usize>,
256    progress: Option<&dyn SyncProgressReporter>,
257) -> Result<()> {
258    if connectors.len() > 1 {
259        println!("Syncing {} connector instances...", connectors.len());
260    }
261
262    // Scan all connectors and collect results
263    let mut scan_results: Vec<(String, Vec<SourceItem>)> = Vec::new();
264    let mut scan_errors: Vec<String> = Vec::new();
265
266    for conn in connectors {
267        let label = conn.source_label();
268        if let Some(p) = progress {
269            p.report(SyncProgressEvent::Discovering {
270                connector: label.to_string(),
271            });
272        }
273        match conn.scan().await {
274            Ok(items) => {
275                scan_results.push((label, items));
276            }
277            Err(e) => {
278                scan_errors.push(format!("{}: {:#}", label, e));
279            }
280        }
281    }
282
283    // Report scan errors but continue with successful scans
284    for err in &scan_errors {
285        eprintln!("Warning: scan failed: {}", err);
286    }
287
288    if scan_results.is_empty() && !scan_errors.is_empty() {
289        bail!("All connector scans failed:\n{}", scan_errors.join("\n"));
290    }
291
292    // Sort for deterministic output ordering
293    scan_results.sort_by(|a, b| a.0.cmp(&b.0));
294
295    // Ingest each target's items (sequential — SQLite writes are serialized)
296    let store = SqliteAppStore::connect(config).await?;
297
298    for (source_label, mut items) in scan_results {
299        // Load checkpoint
300        let checkpoint: Option<i64> = if full {
301            None
302        } else {
303            store.get_checkpoint(&source_label).await?
304        };
305
306        // Filter by checkpoint (skip files not modified since checkpoint)
307        if let Some(cp) = checkpoint {
308            items.retain(|item| item.updated_at.timestamp() > cp);
309        }
310
311        // Apply --since filter
312        if let Some(ref since_str) = since {
313            let since_date = NaiveDate::parse_from_str(since_str, "%Y-%m-%d")?;
314            let since_ts = since_date
315                .and_hms_opt(0, 0, 0)
316                .unwrap()
317                .and_utc()
318                .timestamp();
319            items.retain(|item| item.updated_at.timestamp() >= since_ts);
320        }
321
322        // Apply --until filter
323        if let Some(ref until_str) = until {
324            let until_date = NaiveDate::parse_from_str(until_str, "%Y-%m-%d")?;
325            let until_ts = until_date
326                .and_hms_opt(23, 59, 59)
327                .unwrap()
328                .and_utc()
329                .timestamp();
330            items.retain(|item| item.updated_at.timestamp() <= until_ts);
331        }
332
333        // Apply --limit (per connector instance)
334        if let Some(lim) = limit {
335            items.truncate(lim);
336        }
337
338        if dry_run {
339            println!("sync {} (dry-run)", source_label);
340            println!("  items found: {}", items.len());
341            let total_chunks: usize = items
342                .iter()
343                .map(|item| chunk_text("tmp", &item.body, config.chunking.max_tokens).len())
344                .sum();
345            println!("  estimated chunks: {}", total_chunks);
346            continue;
347        }
348
349        let mut docs_upserted = 0u64;
350        let mut chunks_written = 0u64;
351        let mut embeddings_written = 0u64;
352        let mut embeddings_pending = 0u64;
353        let mut extraction_skipped = 0u64;
354        let mut max_updated: i64 = checkpoint.unwrap_or(0);
355        let ingestor = DocumentIngestor::new(config, &store, &source_label);
356        let total_items = items.len() as u64;
357
358        if let Some(p) = progress {
359            if total_items > 0 {
360                p.report(SyncProgressEvent::Ingesting {
361                    connector: source_label.clone(),
362                    n: 0,
363                    total: total_items,
364                });
365            }
366        }
367
368        for item in items.iter_mut() {
369            match ingestor.ingest(item).await? {
370                DocumentIngestOutcome::ExtractionSkipped => {
371                    extraction_skipped += 1;
372                    continue;
373                }
374                DocumentIngestOutcome::Ingested(result) => {
375                    docs_upserted += 1;
376                    chunks_written += result.chunks_written;
377                    embeddings_written += result.embeddings_written;
378                    embeddings_pending += result.embeddings_pending;
379                }
380            }
381
382            if let Some(p) = progress {
383                let n = docs_upserted;
384                if n.is_multiple_of(INGEST_PROGRESS_INTERVAL) || n == total_items {
385                    p.report(SyncProgressEvent::Ingesting {
386                        connector: source_label.clone(),
387                        n,
388                        total: total_items,
389                    });
390                }
391            }
392
393            let ts = item.updated_at.timestamp();
394            if ts > max_updated {
395                max_updated = ts;
396            }
397        }
398
399        // Update checkpoint
400        store.set_checkpoint(&source_label, max_updated).await?;
401
402        println!("sync {}", source_label);
403        println!("  fetched: {} items", items.len());
404        println!("  upserted documents: {}", docs_upserted);
405        println!("  chunks written: {}", chunks_written);
406        println!("  extraction skipped: {}", extraction_skipped);
407        if config.embedding.is_enabled() {
408            println!("  embeddings written: {}", embeddings_written);
409            println!("  embeddings pending: {}", embeddings_pending);
410        }
411        println!("  checkpoint: {}", max_updated);
412        println!("ok");
413    }
414
415    store.close().await;
416    Ok(())
417}