Skip to main content

context_harness/
targeted_refresh.rs

1//! Trusted, bounded refresh of explicit items without connector discovery.
2
3use 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/// Opaque authority for one configured source in one canonical workspace.
38#[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    /// Resolve configured source authority without opening storage or executing a connector.
48    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
201/// Executor bound to one canonical workspace and its SQLite store.
202pub 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}