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, ®istry, 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}