1use crate::{
4 app_store::{hash_text, AppStore, SqliteAppStore},
5 config::Config,
6 document_ingestor::{prepare_source_item, DocumentPreparationError},
7 embedding,
8 models::{Chunk, SourceItem},
9};
10use anyhow::{bail, Context, Result};
11use context_harness_core::store::Store;
12use globset::{Glob, GlobSet, GlobSetBuilder};
13use serde::Serialize;
14use std::{
15 collections::{HashMap, HashSet},
16 path::{Component, Path, PathBuf},
17};
18
19pub const MAX_TARGETED_REFRESH_ITEMS: usize = 100;
20pub const MAX_TARGETED_REFRESH_PAYLOAD_BYTES: usize = 50_000_000;
21const DEFAULT_MAX_EXTRACT_BYTES: u64 = 50_000_000;
22
23#[derive(Debug)]
24enum SourcePolicy {
25 Paths {
26 include: GlobSet,
27 exclude: GlobSet,
28 },
29 S3 {
30 prefix: String,
31 include: GlobSet,
32 exclude: GlobSet,
33 },
34 Script,
35}
36
37#[derive(Debug)]
39pub struct EnrolledSource {
40 workspace: PathBuf,
41 source_label: String,
42 max_extract_bytes: u64,
43 policy: SourcePolicy,
44}
45
46impl EnrolledSource {
47 pub fn resolve(config: &Config, workspace: &Path, source_label: &str) -> Result<Self> {
49 let workspace = workspace
50 .canonicalize()
51 .context("canonicalizing targeted-refresh workspace")?;
52 let (max_extract_bytes, policy) =
53 if let Some(name) = source_label.strip_prefix("filesystem:") {
54 let source = config
55 .connectors
56 .filesystem
57 .get(name)
58 .context("configured targeted-refresh source not found")?;
59 (
60 source.max_extract_bytes,
61 SourcePolicy::Paths {
62 include: globset(&source.include_globs)?,
63 exclude: path_excludes(&source.exclude_globs)?,
64 },
65 )
66 } else if let Some(name) = source_label.strip_prefix("git:") {
67 let source = config
68 .connectors
69 .git
70 .get(name)
71 .context("configured targeted-refresh source not found")?;
72 (
73 DEFAULT_MAX_EXTRACT_BYTES,
74 SourcePolicy::Paths {
75 include: globset(&source.include_globs)?,
76 exclude: path_excludes(&source.exclude_globs)?,
77 },
78 )
79 } else if let Some(name) = source_label.strip_prefix("s3:") {
80 let source = config
81 .connectors
82 .s3
83 .get(name)
84 .context("configured targeted-refresh source not found")?;
85 (
86 DEFAULT_MAX_EXTRACT_BYTES,
87 SourcePolicy::S3 {
88 prefix: source.prefix.clone(),
89 include: globset(&source.include_globs)?,
90 exclude: s3_excludes(&source.exclude_globs)?,
91 },
92 )
93 } else if let Some(name) = source_label.strip_prefix("script:") {
94 config
95 .connectors
96 .script
97 .get(name)
98 .context("configured targeted-refresh source not found")?;
99 (DEFAULT_MAX_EXTRACT_BYTES, SourcePolicy::Script)
100 } else {
101 bail!("configured targeted-refresh source not found");
102 };
103 Ok(Self {
104 workspace,
105 source_label: source_label.to_owned(),
106 max_extract_bytes,
107 policy,
108 })
109 }
110
111 pub fn source_label(&self) -> &str {
112 &self.source_label
113 }
114
115 fn accepts(&self, source_id: &str) -> bool {
116 match &self.policy {
117 SourcePolicy::Paths { include, exclude } => normalize_relative(source_id)
118 .is_some_and(|path| include.is_match(&path) && !exclude.is_match(&path)),
119 SourcePolicy::S3 {
120 prefix,
121 include,
122 exclude,
123 } => {
124 let relative = if prefix.is_empty() {
125 source_id
126 } else {
127 let normalized = prefix.trim_end_matches('/');
128 let Some(rest) = source_id.strip_prefix(normalized) else {
129 return false;
130 };
131 if !rest.starts_with('/') {
132 return false;
133 }
134 rest.trim_start_matches('/')
135 };
136 !relative.is_empty() && include.is_match(relative) && !exclude.is_match(relative)
137 }
138 SourcePolicy::Script => true,
139 }
140 }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
144#[serde(rename_all = "snake_case")]
145pub enum CanonicalRefreshStatus {
146 Indexed,
147 Unchanged,
148 Rejected,
149 NotAttempted,
150 Failed,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
154#[serde(rename_all = "snake_case")]
155pub enum DerivedRefreshStatus {
156 NotConfigured,
157 Current,
158 Pending,
159 Failed,
160 NotAttempted,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
164#[serde(rename_all = "snake_case")]
165pub enum RefreshReason {
166 EmptyBatch,
167 TooManyItems,
168 PayloadTooLarge,
169 WorkspaceMismatch,
170 SourceMismatch,
171 EmptySourceId,
172 EmptyContentType,
173 InvalidMetadata,
174 RawBodyConflict,
175 ItemTooLarge,
176 SourceIdOutsideScope,
177 DuplicateSourceId,
178 ExtractionFailed,
179 StorageFailed,
180 EmbeddingPending,
181 SidecarPending,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
185pub struct TargetedRefreshItemResult {
186 pub source_id: String,
187 pub document_id: Option<String>,
188 pub canonical: CanonicalRefreshStatus,
189 pub embedding: DerivedRefreshStatus,
190 pub sidecar: DerivedRefreshStatus,
191 pub reasons: Vec<RefreshReason>,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
195pub struct TargetedRefreshReport {
196 pub preflight_passed: bool,
197 pub reasons: Vec<RefreshReason>,
198 pub items: Vec<TargetedRefreshItemResult>,
199}
200
201pub struct TargetedRefresher {
203 config: Config,
204 workspace: PathBuf,
205 store: SqliteAppStore,
206}
207
208impl TargetedRefresher {
209 pub async fn new(mut config: Config, workspace: &Path) -> Result<Self> {
210 let workspace = workspace
211 .canonicalize()
212 .context("canonicalizing targeted-refresh workspace")?;
213 if config.db.path.is_relative() {
214 config.db.path = workspace.join(&config.db.path);
215 }
216 SqliteAppStore::initialize_config(&config).await?;
217 let store = SqliteAppStore::connect(&config).await?;
218 Ok(Self {
219 config,
220 workspace,
221 store,
222 })
223 }
224
225 pub async fn refresh(
226 &self,
227 source: &EnrolledSource,
228 mut items: Vec<SourceItem>,
229 ) -> TargetedRefreshReport {
230 let mut batch_reasons = Vec::new();
231 if items.is_empty() {
232 batch_reasons.push(RefreshReason::EmptyBatch);
233 }
234 if items.len() > MAX_TARGETED_REFRESH_ITEMS {
235 batch_reasons.push(RefreshReason::TooManyItems);
236 }
237 if source.workspace != self.workspace {
238 batch_reasons.push(RefreshReason::WorkspaceMismatch);
239 }
240 if payload_bytes(&items).is_none_or(|bytes| bytes > MAX_TARGETED_REFRESH_PAYLOAD_BYTES) {
241 batch_reasons.push(RefreshReason::PayloadTooLarge);
242 }
243
244 let duplicate_ids = duplicate_ids(&items);
245 let mut item_reasons = Vec::with_capacity(items.len());
246 for item in &mut items {
247 let mut reasons = Vec::new();
248 if item.source != source.source_label {
249 reasons.push(RefreshReason::SourceMismatch);
250 }
251 if item.source_id.trim_ascii().is_empty() {
252 reasons.push(RefreshReason::EmptySourceId);
253 }
254 if item.content_type.trim_ascii().is_empty() {
255 reasons.push(RefreshReason::EmptyContentType);
256 }
257 if !matches!(
258 serde_json::from_str::<serde_json::Value>(&item.metadata_json),
259 Ok(serde_json::Value::Object(_))
260 ) {
261 reasons.push(RefreshReason::InvalidMetadata);
262 }
263 if item.raw_bytes.is_some() && !item.body.is_empty() {
264 reasons.push(RefreshReason::RawBodyConflict);
265 }
266 if item
267 .raw_bytes
268 .as_ref()
269 .is_some_and(|bytes| bytes.len() as u64 > source.max_extract_bytes)
270 {
271 reasons.push(RefreshReason::ItemTooLarge);
272 }
273 if !source.accepts(&item.source_id) {
274 reasons.push(RefreshReason::SourceIdOutsideScope);
275 }
276 if duplicate_ids.contains(&item.source_id) {
277 reasons.push(RefreshReason::DuplicateSourceId);
278 }
279 if reasons.is_empty() {
280 if let Err(error) = prepare_source_item(item, source.max_extract_bytes) {
281 reasons.push(match error {
282 DocumentPreparationError::TooLarge { .. } => RefreshReason::ItemTooLarge,
283 DocumentPreparationError::Extraction(_) => RefreshReason::ExtractionFailed,
284 });
285 }
286 }
287 item_reasons.push(reasons);
288 }
289
290 let rejected = !batch_reasons.is_empty() || item_reasons.iter().any(|r| !r.is_empty());
291 if rejected {
292 return TargetedRefreshReport {
293 preflight_passed: false,
294 reasons: batch_reasons,
295 items: items
296 .into_iter()
297 .zip(item_reasons)
298 .map(|(item, reasons)| TargetedRefreshItemResult {
299 source_id: item.source_id,
300 document_id: None,
301 canonical: if reasons.is_empty() {
302 CanonicalRefreshStatus::NotAttempted
303 } else {
304 CanonicalRefreshStatus::Rejected
305 },
306 embedding: DerivedRefreshStatus::NotAttempted,
307 sidecar: DerivedRefreshStatus::NotAttempted,
308 reasons,
309 })
310 .collect(),
311 };
312 }
313
314 let mut results = Vec::with_capacity(items.len());
315 for item in items {
316 match self
317 .store
318 .replace_source_item_atomic(&item, self.config.chunking.max_tokens)
319 .await
320 {
321 Ok(write) => {
322 let (embedding, sidecar, reasons) = self
323 .refresh_derived(&write.chunks, write.sidecar_current)
324 .await;
325 results.push(TargetedRefreshItemResult {
326 source_id: item.source_id,
327 document_id: Some(write.document_id),
328 canonical: CanonicalRefreshStatus::Indexed,
329 embedding,
330 sidecar,
331 reasons,
332 });
333 }
334 Err(_) => results.push(TargetedRefreshItemResult {
335 source_id: item.source_id,
336 document_id: None,
337 canonical: CanonicalRefreshStatus::Failed,
338 embedding: DerivedRefreshStatus::NotAttempted,
339 sidecar: DerivedRefreshStatus::NotAttempted,
340 reasons: vec![RefreshReason::StorageFailed],
341 }),
342 }
343 }
344 TargetedRefreshReport {
345 preflight_passed: true,
346 reasons: Vec::new(),
347 items: results,
348 }
349 }
350
351 async fn refresh_derived(
352 &self,
353 chunks: &[Chunk],
354 sidecar_current_after_reset: bool,
355 ) -> (
356 DerivedRefreshStatus,
357 DerivedRefreshStatus,
358 Vec<RefreshReason>,
359 ) {
360 let sidecar_configured =
361 matches!(self.config.vector_index.backend.as_str(), "auto" | "zvec");
362 if !self.config.embedding.is_enabled() {
363 return (
364 DerivedRefreshStatus::NotConfigured,
365 if sidecar_configured && sidecar_current_after_reset {
366 DerivedRefreshStatus::Current
367 } else if sidecar_configured {
368 DerivedRefreshStatus::Pending
369 } else {
370 DerivedRefreshStatus::NotConfigured
371 },
372 if sidecar_configured && !sidecar_current_after_reset {
373 vec![RefreshReason::SidecarPending]
374 } else {
375 Vec::new()
376 },
377 );
378 }
379
380 let provider = match embedding::create_provider(&self.config.embedding) {
381 Ok(provider) => provider,
382 Err(_) => {
383 return (
384 DerivedRefreshStatus::Pending,
385 if sidecar_configured {
386 DerivedRefreshStatus::Pending
387 } else {
388 DerivedRefreshStatus::NotConfigured
389 },
390 vec![RefreshReason::EmbeddingPending],
391 )
392 }
393 };
394 let model = provider.model_name().to_owned();
395 let mut embedding_pending = false;
396 let mut sidecar_pending = !sidecar_current_after_reset;
397 for batch in chunks.chunks(self.config.embedding.batch_size) {
398 let texts: Vec<_> = batch.iter().map(|chunk| chunk.text.clone()).collect();
399 let vectors =
400 match embedding::embed_texts(provider.as_ref(), &self.config.embedding, &texts)
401 .await
402 {
403 Ok(vectors) if vectors.len() == batch.len() => vectors,
404 _ => {
405 embedding_pending = true;
406 continue;
407 }
408 };
409 for (chunk, vector) in batch.iter().zip(vectors) {
410 let hash = hash_text(&chunk.text);
411 if self
412 .store
413 .upsert_embedding(
414 &chunk.id,
415 &chunk.document_id,
416 &vector,
417 &model,
418 provider.dims(),
419 &hash,
420 )
421 .await
422 .is_err()
423 {
424 if self
425 .store
426 .get_embedding_hash(&chunk.id, &model)
427 .await
428 .ok()
429 .flatten()
430 .as_deref()
431 == Some(hash.as_str())
432 {
433 sidecar_pending = true;
434 } else {
435 embedding_pending = true;
436 }
437 }
438 }
439 }
440 let mut reasons = Vec::new();
441 if embedding_pending {
442 reasons.push(RefreshReason::EmbeddingPending);
443 }
444 if sidecar_pending {
445 reasons.push(RefreshReason::SidecarPending);
446 }
447 (
448 if embedding_pending {
449 DerivedRefreshStatus::Pending
450 } else {
451 DerivedRefreshStatus::Current
452 },
453 if !sidecar_configured {
454 DerivedRefreshStatus::NotConfigured
455 } else if sidecar_pending {
456 DerivedRefreshStatus::Pending
457 } else {
458 DerivedRefreshStatus::Current
459 },
460 reasons,
461 )
462 }
463}
464
465fn payload_bytes(items: &[SourceItem]) -> Option<usize> {
466 items.iter().try_fold(0usize, |total, item| {
467 let strings = item
468 .source
469 .len()
470 .checked_add(item.source_id.len())?
471 .checked_add(item.source_url.as_ref().map_or(0, String::len))?
472 .checked_add(item.title.as_ref().map_or(0, String::len))?
473 .checked_add(item.author.as_ref().map_or(0, String::len))?
474 .checked_add(item.content_type.len())?
475 .checked_add(item.body.len())?
476 .checked_add(item.metadata_json.len())?
477 .checked_add(item.raw_json.as_ref().map_or(0, String::len))?
478 .checked_add(item.raw_bytes.as_ref().map_or(0, Vec::len))?;
479 total.checked_add(strings)
480 })
481}
482
483fn duplicate_ids(items: &[SourceItem]) -> HashSet<String> {
484 let mut counts = HashMap::new();
485 for item in items {
486 *counts.entry(item.source_id.clone()).or_insert(0usize) += 1;
487 }
488 counts
489 .into_iter()
490 .filter_map(|(id, count)| (count > 1).then_some(id))
491 .collect()
492}
493
494fn normalize_relative(value: &str) -> Option<String> {
495 let path = Path::new(value);
496 if path.is_absolute() {
497 return None;
498 }
499 let mut parts = Vec::new();
500 for component in path.components() {
501 match component {
502 Component::Normal(part) => parts.push(part.to_str()?.to_owned()),
503 _ => return None,
504 }
505 }
506 (!parts.is_empty()).then(|| parts.join("/"))
507}
508
509fn globset(patterns: &[String]) -> Result<GlobSet> {
510 let mut builder = GlobSetBuilder::new();
511 for pattern in patterns {
512 builder.add(Glob::new(pattern)?);
513 }
514 Ok(builder.build()?)
515}
516
517fn path_excludes(configured: &[String]) -> Result<GlobSet> {
518 let mut patterns = vec![
519 "**/.git/**".into(),
520 "**/target/**".into(),
521 "**/node_modules/**".into(),
522 ];
523 patterns.extend_from_slice(configured);
524 globset(&patterns)
525}
526
527fn s3_excludes(configured: &[String]) -> Result<GlobSet> {
528 let mut patterns = vec!["**/.git/**".into(), "**/node_modules/**".into()];
529 patterns.extend_from_slice(configured);
530 globset(&patterns)
531}