Skip to main content

lean_ctx/core/
index_orchestrator.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::{Arc, Mutex, OnceLock};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use serde::Serialize;
7
8use crate::core::bm25_index::BM25Index;
9use crate::core::graph_index::{self, ProjectIndex};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12enum State {
13    Idle,
14    Building,
15    Ready,
16    Failed,
17}
18
19#[derive(Debug, Clone)]
20struct Component {
21    state: State,
22    started_ms: Option<u64>,
23    finished_ms: Option<u64>,
24    duration_ms: Option<u64>,
25    last_error: Option<String>,
26    /// Human-readable outcome detail surfaced to operators (e.g. doc count +
27    /// persisted size, or the "not persisted: too large …" remedy). Independent
28    /// of `last_error` so a *successful* build can still carry a warning note.
29    note: Option<String>,
30}
31
32impl Component {
33    fn new() -> Self {
34        Self {
35            state: State::Idle,
36            started_ms: None,
37            finished_ms: None,
38            duration_ms: None,
39            last_error: None,
40            note: None,
41        }
42    }
43}
44
45#[derive(Debug)]
46struct ProjectBuild {
47    worker_running: bool,
48    /// Cleared on worker claim; set when the graph phase of this run finishes.
49    /// Used by CLI wait loops so they do not exit during the claim→start gap
50    /// or the graph→BM25 handoff (search pre-warm keeps `worker_running`).
51    graph_run_done: bool,
52    /// Cleared on worker claim; set when the BM25 phase of this run finishes.
53    bm25_run_done: bool,
54    /// Set the first time a heavy-index tool lazily pre-warms this root (#152).
55    /// Prevents re-triggering a full rebuild on every subsequent dispatch — the
56    /// tools' own `load_or_build` paths handle staleness from then on.
57    warm_triggered: bool,
58    graph: Component,
59    bm25: Component,
60    /// Dense embedding index (semantic search). Built after BM25 as Phase 3.
61    /// Tracked separately so the orchestrator does not block on a missing ONNX
62    /// model — the status lets users see why semantic stays cold (#249).
63    semantic: Component,
64}
65
66impl ProjectBuild {
67    fn new() -> Self {
68        Self {
69            worker_running: false,
70            graph_run_done: true,
71            bm25_run_done: true,
72            warm_triggered: false,
73            graph: Component::new(),
74            bm25: Component::new(),
75            semantic: Component::new(),
76        }
77    }
78}
79
80// Lock ordering (see rust/LOCK_ORDERING.md):
81//   L1 = REGISTRY outer Mutex  (the HashMap guard)
82//   L2 = per-project Arc<Mutex<ProjectBuild>>  (inner guard)
83//
84// Invariant: L1 must NEVER be held while locking L2.
85// `entry_for()` enforces this by cloning the Arc and dropping L1 before
86// the caller acquires L2.
87static REGISTRY: OnceLock<Mutex<HashMap<String, Arc<Mutex<ProjectBuild>>>>> = OnceLock::new();
88
89fn registry() -> &'static Mutex<HashMap<String, Arc<Mutex<ProjectBuild>>>> {
90    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
91}
92
93fn entry_for(project_root: &str) -> Arc<Mutex<ProjectBuild>> {
94    let mut map = registry()
95        .lock()
96        .unwrap_or_else(std::sync::PoisonError::into_inner);
97    map.entry(project_root.to_string())
98        .or_insert_with(|| Arc::new(Mutex::new(ProjectBuild::new())))
99        .clone()
100}
101
102fn now_ms() -> u64 {
103    SystemTime::now()
104        .duration_since(UNIX_EPOCH)
105        .unwrap_or_default()
106        .as_millis() as u64
107}
108
109/// Per-repo lock name for serializing the BM25 build across processes, mirroring
110/// the `graph-idx-<hash>` lock (see LOCK_ORDERING.md). The distinct `bm25-` vs
111/// `graph-` prefix keeps the graph and BM25 builds from serializing against each
112/// other while still preventing N processes from rebuilding either in parallel.
113fn bm25_index_lock_name(root: &Path) -> String {
114    format!(
115        "bm25-idx-{}",
116        &crate::core::index_namespace::namespace_hash(root)[..8]
117    )
118}
119
120fn start_component(c: &mut Component) {
121    c.state = State::Building;
122    c.started_ms = Some(now_ms());
123    c.finished_ms = None;
124    c.duration_ms = None;
125    c.last_error = None;
126    c.note = None;
127}
128
129fn finish_ok(c: &mut Component) {
130    c.state = State::Ready;
131    let end = now_ms();
132    c.finished_ms = Some(end);
133    c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
134}
135
136fn finish_err(c: &mut Component, e: String) {
137    c.state = State::Failed;
138    let end = now_ms();
139    c.finished_ms = Some(end);
140    c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
141    c.last_error = Some(e);
142}
143
144/// The index warmth a tool benefits from. Drives lazy, demand-driven warming
145/// (issue #152) so the server no longer scans the whole project eagerly on every
146/// `initialize` — a session that only uses `ctx_read`/`ctx_shell`/`ctx_tree`
147/// pays zero indexing cost.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum WarmNeed {
150    /// No prebuilt index needed.
151    None,
152    /// Only the resident line-search (trigram) index — cheap, used by `ctx_search`.
153    Search,
154    /// Full project indices (graph + BM25; this also warms the search index).
155    Heavy,
156}
157
158/// Classify a tool by the index warmth it benefits from. Unknown tools default
159/// to [`WarmNeed::None`]; a heavy tool mis-classified as `None` still works — it
160/// just builds its index synchronously on first use instead of being pre-warmed.
161#[must_use]
162pub fn warm_need_for_tool(tool: &str) -> WarmNeed {
163    match tool {
164        "ctx_search" => WarmNeed::Search,
165        // Tools that build/consume the graph, call-graph, BM25 or artifact index.
166        "ctx_graph"
167        | "ctx_callgraph"
168        | "ctx_routes"
169        | "ctx_repomap"
170        | "ctx_impact"
171        | "ctx_artifacts"
172        | "ctx_semantic_search"
173        | "ctx_provider"
174        | "ctx_compose"
175        | "ctx_explore"
176        | "ctx_review" => WarmNeed::Heavy,
177        _ => WarmNeed::None,
178    }
179}
180
181/// Lazily warm the indices a tool needs, deduped per root. Never blocks (all
182/// work is spawned in the background) and is safe to call on every dispatch.
183///
184/// Returns `true` only when this call is the *first* heavy pre-warm for `root`
185/// in this process — the caller can use that signal to warm secondary roots once
186/// without re-reading session state on every dispatch.
187pub fn ensure_warm_for_tool(project_root: &str, tool: &str) -> bool {
188    if project_root.is_empty() {
189        return false;
190    }
191    match warm_need_for_tool(tool) {
192        WarmNeed::None => false,
193        WarmNeed::Search => {
194            // The search index has its own TTL + background-rebuild dedup, so it
195            // is safe (and cheap) to nudge on every `ctx_search`.
196            crate::core::search_index::ensure_background(project_root, true, false);
197            false
198        }
199        WarmNeed::Heavy => {
200            let entry = entry_for(project_root);
201            let first_warm = {
202                let mut s = entry
203                    .lock()
204                    .unwrap_or_else(std::sync::PoisonError::into_inner);
205                if s.warm_triggered {
206                    false
207                } else {
208                    s.warm_triggered = true;
209                    true
210                }
211            };
212            if first_warm {
213                ensure_all_background(project_root);
214            }
215            first_warm
216        }
217    }
218}
219
220/// Stack size for background index workers. Large enough that deep ASTs and
221/// graph traversals cannot overflow it (the #378 SIGABRT class). The AST walks
222/// are iterative now too, so this is defense-in-depth.
223const INDEXER_STACK_BYTES: usize = 16 * 1024 * 1024;
224
225/// Fire-and-forget: ask a running daemon to own the index build for `root`
226/// (#460, shared-indexer-daemon / thin clients).
227///
228/// The daemon is the single long-lived, machine-wide indexer. Once it holds the
229/// per-repo `graph-idx`/`bm25-idx` build locks, every session load-shares its
230/// on-disk result instead of each running a full scan during a cold boot wave —
231/// turning N simultaneous index passes into ~one. Strictly additive and
232/// best-effort: it runs on its own thread (no ambient runtime to nest into), is
233/// skipped when we *are* the daemon or when no daemon is reachable, and never
234/// blocks the caller. The local build started right after remains the fallback,
235/// so indexing always works with no daemon present.
236fn nudge_daemon_index(project_root: &str) {
237    // The daemon must never delegate the build to itself.
238    if crate::daemon::is_foreground_daemon() {
239        return;
240    }
241    let root = project_root.to_string();
242    let _ = std::thread::Builder::new()
243        .name("leanctx-index-nudge".to_string())
244        .spawn(move || {
245            if !crate::daemon::is_daemon_running() {
246                return;
247            }
248            let Ok(rt) = tokio::runtime::Runtime::new() else {
249                return;
250            };
251            let body = serde_json::json!({ "root": root }).to_string();
252            rt.block_on(async {
253                let _ = crate::daemon_client::try_daemon_request("POST", "/v1/index/ensure", &body)
254                    .await;
255            });
256        });
257}
258
259/// Try to claim the per-root build slot. Returns `false` when a worker is
260/// already running for `project_root` (the claim is released by the build
261/// worker itself when it finishes).
262fn try_claim_worker(project_root: &str) -> bool {
263    let state = entry_for(project_root);
264    let mut s = state
265        .lock()
266        .unwrap_or_else(std::sync::PoisonError::into_inner);
267    if s.worker_running {
268        false
269    } else {
270        s.worker_running = true;
271        s.graph_run_done = false;
272        s.bm25_run_done = false;
273        true
274    }
275}
276
277pub fn ensure_all_background(project_root: &str) {
278    if !try_claim_worker(project_root) {
279        return;
280    }
281
282    // #460: hand the build to the daemon (the single machine-wide indexer) when
283    // one is running and we aren't it. Deduped naturally — we only reach here
284    // when this process is actually about to start a build. Purely additive: the
285    // local build below still runs and load-shares via the per-repo locks.
286    //
287    // #735 exception: with a per-run CLI filter overlay active, the daemon
288    // (which builds with *its* config, not the overlay) could overwrite the
289    // filtered result — the one-off run keeps the build local instead.
290    if !crate::core::index_filter::cli_overlay_active() {
291        nudge_daemon_index(project_root);
292    }
293
294    let state = entry_for(project_root);
295    let root = project_root.to_string();
296    let indexer = move || run_build_worker(&root);
297
298    // Indexing parses large ASTs and traverses graphs; give the worker a
299    // generous stack as defense-in-depth against deep-recursion overflow (the
300    // #378 SIGABRT class) and a name so it is identifiable in crash dumps.
301    let spawned = std::thread::Builder::new()
302        .name("leanctx-index".to_string())
303        .stack_size(INDEXER_STACK_BYTES)
304        .spawn(indexer);
305    if spawned.is_err() {
306        // The OS refused a new thread (rare). Clear the in-flight flag so a
307        // later trigger retries instead of assuming a build runs forever.
308        let mut s = state
309            .lock()
310            .unwrap_or_else(std::sync::PoisonError::into_inner);
311        s.worker_running = false;
312    }
313}
314
315/// Actual per-root build work. Heavy phases run in a fixed, memory-bounded order:
316/// graph → reclaim → BM25 → reclaim → resident search pre-warm. Blocking — callers
317/// own the threading. Requires the caller to have claimed the worker slot via
318/// [`try_claim_worker`]; releases it on exit.
319fn run_build_worker(root: &str) {
320    // #790: start memory guardian for daemon-spawned builds. Previously only
321    // the CLI path (index_cmd.rs) started the guardian, leaving background
322    // builds (MCP `ensure_all_background`, watcher, graph_coordinator) to run
323    // without RSS pressure checks — the guardian's abort/pressure flags never
324    // fired. `start_guard` is idempotent: a second call within the same
325    // process is a no-op.
326    crate::core::memory_guard::start_guard(std::sync::Arc::new(|level| {
327        tracing::warn!(
328            "[build_worker] memory pressure: {level:?} — background tasks will throttle"
329        );
330        if level >= crate::core::memory_guard::PressureLevel::Hard {
331            crate::core::content_cache::clear();
332        }
333        crate::core::memory_guard::force_purge();
334        true
335    }));
336
337    // Graph, BM25, and the resident search index each retain substantial live
338    // state. Running them concurrently defeats cache eviction because active
339    // builders still own their allocations. Keep phases strictly ordered and
340    // reclaim between them (#918); intra-phase Rayon parallelism remains intact.
341
342    let graph_state = entry_for(root);
343    let graph_root = root.to_string();
344    let build_graph = move || {
345        {
346            let mut s = graph_state
347                .lock()
348                .unwrap_or_else(std::sync::PoisonError::into_inner);
349            start_component(&mut s.graph);
350        }
351        // Graph scan has no reliable unit total → indeterminate. Guard clears on exit.
352        let guard = crate::core::index_progress::ProgressGuard::new(
353            graph_root.clone(),
354            crate::core::index_progress::IndexComponent::Graph,
355        );
356        guard.report(0, 0);
357        let graph_result = std::panic::catch_unwind(|| {
358            let (idx, _cache) = graph_index::scan_with_content_cache(&graph_root);
359            if let Err(e) = idx.save() {
360                tracing::warn!("[index_orchestrator: graph save failed: {e}]");
361            }
362            crate::core::code_health::persist::refresh_if_stale(&graph_root, &idx);
363        });
364        drop(guard);
365        {
366            let mut s = graph_state
367                .lock()
368                .unwrap_or_else(std::sync::PoisonError::into_inner);
369            if let Ok(()) = graph_result {
370                finish_ok(&mut s.graph);
371            } else {
372                finish_err(&mut s.graph, "graph index build panicked".to_string());
373            }
374            s.graph_run_done = true;
375        }
376    };
377
378    let bm25_state = entry_for(root);
379    let bm25_root = root.to_string();
380    let build_bm25 = move || {
381        {
382            let mut s = bm25_state
383                .lock()
384                .unwrap_or_else(std::sync::PoisonError::into_inner);
385            start_component(&mut s.bm25);
386        }
387        let _progress = crate::core::index_progress::ProgressGuard::new(
388            bm25_root.clone(),
389            crate::core::index_progress::IndexComponent::Bm25,
390        );
391        let bm = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
392            let root_pb = Path::new(&bm25_root);
393            let lock_name = bm25_index_lock_name(root_pb);
394            let _lock = crate::core::startup_guard::try_acquire_lock(
395                &lock_name,
396                std::time::Duration::from_millis(800),
397                std::time::Duration::from_mins(3),
398            );
399            if _lock.is_none() {
400                tracing::info!(
401                    "[bm25: another process is building {bm25_root} — loading the shared index]"
402                );
403                let idx = BM25Index::load(root_pb).unwrap_or_default();
404                return (idx.doc_count, None);
405            }
406            let idx = BM25Index::load_or_build(root_pb);
407            let outcome = idx.save(root_pb);
408            (idx.doc_count, Some(outcome))
409        }));
410        {
411            let mut s = bm25_state
412                .lock()
413                .unwrap_or_else(std::sync::PoisonError::into_inner);
414            if let Ok((doc_count, save_res)) = bm {
415                finish_ok(&mut s.bm25);
416                s.bm25.note = Some(match save_res {
417                    Some(outcome) => bm25_build_note(doc_count, &outcome),
418                    None => format!(
419                        "loaded shared BM25 index ({doc_count} chunks) — build in progress in another process"
420                    ),
421                });
422            } else {
423                finish_err(&mut s.bm25, "bm25 build panicked".to_string());
424            }
425            s.bm25_run_done = true;
426        }
427    };
428
429    run_memory_bounded_phases(
430        build_graph,
431        build_bm25,
432        || crate::core::search_index::ensure_background(root, true, false),
433        || {
434            crate::core::content_cache::clear();
435            crate::core::memory_guard::force_purge();
436        },
437    );
438
439    // Post-build memory reclamation: the parallel build allocates large
440    // transient structures (file contents, token vectors, inverted postings).
441    // After merge+save these are freed, but jemalloc retains dirty pages in
442    // its arena — inflating RSS far past the logical working set. Purging
443    // immediately returns those pages to the OS, keeping the serve process
444    // close to the configured max_ram_percent target.
445    crate::core::content_cache::trim_oldest_percent(75);
446    crate::core::memory_guard::force_purge();
447
448    let final_state = entry_for(root);
449    let mut s = final_state
450        .lock()
451        .unwrap_or_else(std::sync::PoisonError::into_inner);
452    s.worker_running = false;
453}
454
455/// Execute index phases without overlapping their retained working sets.
456/// Kept as a small pure coordinator so ordering remains regression-testable.
457fn run_memory_bounded_phases<G, B, S, R>(graph: G, bm25: B, search: S, mut reclaim: R)
458where
459    G: FnOnce(),
460    B: FnOnce(),
461    S: FnOnce(),
462    R: FnMut(),
463{
464    graph();
465    reclaim();
466    bm25();
467    reclaim();
468    search();
469}
470
471/// Build only the semantic (dense embedding) index from the existing BM25 index.
472/// The BM25 index must already exist on disk — this function loads it and runs
473/// `embedding_index::build_or_update`. Updates the in-memory semantic component
474/// state on completion.
475pub fn build_semantic(project_root: &str) {
476    let state = entry_for(project_root);
477    let root = Path::new(project_root);
478
479    {
480        let mut s = state
481            .lock()
482            .unwrap_or_else(std::sync::PoisonError::into_inner);
483        start_component(&mut s.semantic);
484    }
485
486    // Guard clears progress on every exit (incl. early returns / panics).
487    let _progress = crate::core::index_progress::ProgressGuard::new(
488        project_root.to_string(),
489        crate::core::index_progress::IndexComponent::Semantic,
490    );
491
492    let bm25_idx = try_load_bm25_index(project_root);
493    match bm25_idx.as_ref() {
494        Some(idx) if idx.doc_count > 0 => {
495            let outcome = crate::core::embedding_index::build_or_update(root, idx);
496            let mut s = state
497                .lock()
498                .unwrap_or_else(std::sync::PoisonError::into_inner);
499            match outcome {
500                crate::core::embedding_index::EmbeddingBuildOutcome::Ready => {
501                    finish_ok(&mut s.semantic);
502                }
503                crate::core::embedding_index::EmbeddingBuildOutcome::Skipped => {
504                    finish_ok(&mut s.semantic);
505                    s.semantic.note = Some(
506                        "embeddings disabled by feature flag or config (search.dense_enabled / memory_profile)"
507                            .to_string(),
508                    );
509                }
510                crate::core::embedding_index::EmbeddingBuildOutcome::ModelNotAvailable(
511                    ref reason,
512                ) => {
513                    // Not a hard failure — semantic is optional. Surface the reason
514                    // so users know why dense search is cold (#249).
515                    s.semantic.state = State::Idle;
516                    s.semantic.note = Some(format!("embedding model not available: {reason}"));
517                }
518                crate::core::embedding_index::EmbeddingBuildOutcome::Failed => {
519                    finish_err(
520                        &mut s.semantic,
521                        "embedding build failed (see logs)".to_string(),
522                    );
523                }
524            }
525        }
526        _ => {
527            let mut s = state
528                .lock()
529                .unwrap_or_else(std::sync::PoisonError::into_inner);
530            // No BM25 docs → nothing to embed. Leave semantic Idle with a note.
531            s.semantic.state = State::Idle;
532            s.semantic.note =
533                Some("BM25 index is empty or unavailable — nothing to embed".to_string());
534        }
535    }
536}
537
538/// Ensure background indexing for all extra roots (in addition to the primary).
539/// Each extra root that is not a subdirectory of `primary_root` gets its own
540/// graph + BM25 index. Capped at `MAX_EXTRA_ROOT_BUILDS` to prevent runaway.
541const MAX_EXTRA_ROOT_BUILDS: usize = 8;
542
543pub fn ensure_extra_roots_background(primary_root: &str, extra_roots: &[String]) {
544    let primary = Path::new(primary_root);
545    let mut queue: Vec<String> = Vec::new();
546    for root in extra_roots {
547        if queue.len() >= MAX_EXTRA_ROOT_BUILDS {
548            break;
549        }
550        let rp = Path::new(root);
551        if !rp.is_dir() {
552            continue;
553        }
554        // Skip if extra_root is inside primary (already indexed by the primary scan)
555        if rp.starts_with(primary) {
556            continue;
557        }
558        // Skip if primary is inside this extra_root (avoid double-indexing the parent)
559        if primary.starts_with(rp) {
560            continue;
561        }
562        queue.push(root.clone());
563    }
564    if queue.is_empty() {
565        return;
566    }
567
568    // #685: build extra roots *sequentially* on one supervisor thread. The old
569    // per-root `ensure_all_background` fan-out ran up to MAX_EXTRA_ROOT_BUILDS
570    // graph+BM25 pairs concurrently (each with rayon pools inside) — on the
571    // reported multi-root setup (1M+ files across 6+ roots) the combined
572    // transient build state outran the guardian to 75 GB RSS. One root at a
573    // time keeps peak memory bounded to a single build while still warming
574    // every root; the guardian check between roots stops the queue as soon as
575    // pressure appears.
576    let spawned = std::thread::Builder::new()
577        .name("leanctx-extra-roots".to_string())
578        .stack_size(INDEXER_STACK_BYTES)
579        .spawn(move || {
580            for root in queue {
581                if crate::core::memory_guard::is_under_pressure()
582                    || crate::core::memory_guard::abort_requested()
583                {
584                    tracing::warn!(
585                        "[index_orchestrator: skipping remaining extra-root builds under memory pressure]"
586                    );
587                    break;
588                }
589                if !try_claim_worker(&root) {
590                    continue; // already building elsewhere
591                }
592                nudge_daemon_index(&root);
593                run_build_worker(&root);
594            }
595        });
596    if let Err(e) = spawned {
597        tracing::warn!("[index_orchestrator: could not spawn extra-roots worker: {e}]");
598    }
599}
600
601/// Build a human-readable outcome note for a finished BM25 build, including the
602/// indexed chunk count and whether the index was persisted to disk. A
603/// "too large" refusal carries the exact remedy so the operator (or agent) is
604/// never left guessing why search/ranking stays cold (issue #249).
605fn bm25_build_note(
606    doc_count: usize,
607    save: &std::io::Result<crate::core::bm25_index::SaveOutcome>,
608) -> String {
609    use crate::core::bm25_index::SaveOutcome;
610    match save {
611        Ok(SaveOutcome::Persisted { compressed_bytes }) => format!(
612            "indexed {doc_count} chunks, {:.1} MB persisted",
613            *compressed_bytes as f64 / 1_048_576.0
614        ),
615        Ok(SaveOutcome::SkippedTooLarge {
616            compressed_bytes,
617            limit_bytes,
618        }) => format!(
619            "indexed {doc_count} chunks but NOT persisted to disk: compressed {:.1} MB exceeds the {:.0} MB cap. \
620             Raise it via LEAN_CTX_BM25_MAX_CACHE_MB (or bm25_max_cache_mb in config) or add extra_ignore_patterns, \
621             then run `lean-ctx reindex`. Until then the index is rebuilt from scratch on every cold start.",
622            *compressed_bytes as f64 / 1_048_576.0,
623            *limit_bytes as f64 / 1_048_576.0
624        ),
625        Err(e) => format!("indexed {doc_count} chunks but persisting failed: {e}"),
626    }
627}
628
629/// Lightweight, allocation-frugal snapshot of the BM25 component for the
630/// in-call composer/search messaging. Avoids the heavier [`disk_status`] walk.
631#[derive(Debug, Clone)]
632pub struct Bm25Summary {
633    pub state: &'static str,
634    /// While building: elapsed so far. Otherwise: last build duration.
635    pub elapsed_ms: Option<u64>,
636    pub note: Option<String>,
637    pub last_error: Option<String>,
638}
639
640/// Lightweight snapshot of the semantic (dense embedding) component.
641#[derive(Debug, Clone)]
642pub struct SemanticSummary {
643    pub state: &'static str,
644    pub elapsed_ms: Option<u64>,
645    pub note: Option<String>,
646    pub last_error: Option<String>,
647}
648
649/// Shared helper: compute (state_str, elapsed_ms) for a component.
650/// Deduplicates the elapsed-while-building logic and state-to-string mapping
651/// between bm25_summary and semantic_summary.
652fn component_elapsed_and_state(c: &Component) -> (&'static str, Option<u64>) {
653    let elapsed_ms = if matches!(c.state, State::Building) {
654        c.started_ms.map(|start| now_ms().saturating_sub(start))
655    } else {
656        c.duration_ms
657    };
658    let state = match c.state {
659        State::Idle => "idle",
660        State::Building => "building",
661        State::Ready => "ready",
662        State::Failed => "failed",
663    };
664    (state, elapsed_ms)
665}
666
667pub fn semantic_summary(project_root: &str) -> SemanticSummary {
668    let entry = entry_for(project_root);
669    let s = entry
670        .lock()
671        .unwrap_or_else(std::sync::PoisonError::into_inner);
672    let c = &s.semantic;
673    let (state, elapsed_ms) = component_elapsed_and_state(c);
674    SemanticSummary {
675        state,
676        elapsed_ms,
677        note: c.note.clone(),
678        last_error: c.last_error.clone(),
679    }
680}
681
682pub fn bm25_summary(project_root: &str) -> Bm25Summary {
683    let entry = entry_for(project_root);
684    let s = entry
685        .lock()
686        .unwrap_or_else(std::sync::PoisonError::into_inner);
687    let c = &s.bm25;
688    let (state, elapsed_ms) = component_elapsed_and_state(c);
689    Bm25Summary {
690        state,
691        elapsed_ms,
692        note: c.note.clone(),
693        last_error: c.last_error.clone(),
694    }
695}
696
697pub fn try_load_graph_index(project_root: &str) -> Option<ProjectIndex> {
698    // Resident cache: avoids re-materializing the index from the property graph
699    // (SQLite query) on every graph-touching query. Returns an in-memory clone.
700    crate::core::graph_cache::get_cached(project_root).map(|arc| (*arc).clone())
701}
702
703pub fn try_load_bm25_index(project_root: &str) -> Option<BM25Index> {
704    BM25Index::load(Path::new(project_root))
705}
706
707/// Typed progress snapshot for one index component.
708#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
709pub struct SlotProgress {
710    pub building: bool,
711    pub done: u64,
712    pub total: u64,
713}
714
715impl SlotProgress {
716    pub fn is_determinate(&self) -> bool {
717        self.building && self.total > 0
718    }
719}
720
721/// Root-scoped view of index build progress for CLI wait loops.
722#[derive(Debug, Clone, PartialEq, Eq)]
723pub struct ProgressView {
724    pub worker_running: bool,
725    /// Graph phase of the current worker run has finished (ok or err).
726    pub graph_run_done: bool,
727    /// BM25 phase of the current worker run has finished (ok or err).
728    pub bm25_run_done: bool,
729    pub graph: SlotProgress,
730    pub bm25: SlotProgress,
731    pub semantic: SlotProgress,
732}
733
734impl ProgressView {
735    /// Graph + BM25 pipeline still in flight for this root.
736    ///
737    /// True from worker claim through BM25 completion — covers the claim→start
738    /// race and the graph→BM25 handoff without waiting for search pre-warm.
739    pub fn graph_bm25_active(&self) -> bool {
740        self.worker_running && !(self.graph_run_done && self.bm25_run_done)
741    }
742
743    pub fn semantic_active(&self) -> bool {
744        self.semantic.building
745    }
746}
747
748/// Snapshot progress for a project root (typed; no JSON scrape).
749pub fn progress_view(project_root: &str) -> ProgressView {
750    use crate::core::index_progress::{self, IndexComponent};
751    let (worker_running, graph_run_done, bm25_run_done, graph_b, bm25_b, sem_b) = {
752        let state = entry_for(project_root);
753        let s = state
754            .lock()
755            .unwrap_or_else(std::sync::PoisonError::into_inner);
756        (
757            s.worker_running,
758            s.graph_run_done,
759            s.bm25_run_done,
760            matches!(s.graph.state, State::Building),
761            matches!(s.bm25.state, State::Building),
762            matches!(s.semantic.state, State::Building),
763        )
764    };
765    let g = index_progress::get(project_root, IndexComponent::Graph);
766    let b = index_progress::get(project_root, IndexComponent::Bm25);
767    let sem = index_progress::get(project_root, IndexComponent::Semantic);
768    ProgressView {
769        worker_running,
770        graph_run_done,
771        bm25_run_done,
772        graph: SlotProgress {
773            building: graph_b,
774            done: g.done,
775            total: g.total,
776        },
777        bm25: SlotProgress {
778            building: bm25_b,
779            done: b.done,
780            total: b.total,
781        },
782        semantic: SlotProgress {
783            building: sem_b,
784            done: sem.done,
785            total: sem.total,
786        },
787    }
788}
789
790/// Returns true if any project is currently building its indices.
791pub fn is_building() -> bool {
792    let map = registry()
793        .lock()
794        .unwrap_or_else(std::sync::PoisonError::into_inner);
795    map.values().any(|entry| {
796        let st = entry
797            .lock()
798            .unwrap_or_else(std::sync::PoisonError::into_inner);
799        matches!(st.bm25.state, State::Building)
800            || matches!(st.graph.state, State::Building)
801            || matches!(st.semantic.state, State::Building)
802    })
803}
804
805#[derive(Debug, Serialize)]
806struct ComponentStatus<'a> {
807    state: &'a str,
808    started_ms: Option<u64>,
809    finished_ms: Option<u64>,
810    duration_ms: Option<u64>,
811    last_error: Option<&'a str>,
812    #[serde(skip_serializing_if = "Option::is_none")]
813    note: Option<&'a str>,
814    /// Units completed while `building` (files/chunks). Omitted when idle/ready.
815    #[serde(skip_serializing_if = "Option::is_none")]
816    progress_done: Option<u64>,
817    /// Total units; `Some(0)` while building means indeterminate.
818    #[serde(skip_serializing_if = "Option::is_none")]
819    progress_total: Option<u64>,
820}
821
822fn component_status(
823    c: &Component,
824    progress: crate::core::index_progress::ProgressSnapshot,
825) -> ComponentStatus<'_> {
826    let (progress_done, progress_total) = if matches!(c.state, State::Building) {
827        (Some(progress.done), Some(progress.total))
828    } else {
829        (None, None)
830    };
831    ComponentStatus {
832        state: match c.state {
833            State::Idle => "idle",
834            State::Building => "building",
835            State::Ready => "ready",
836            State::Failed => "failed",
837        },
838        started_ms: c.started_ms,
839        finished_ms: c.finished_ms,
840        duration_ms: c.duration_ms,
841        last_error: c.last_error.as_deref(),
842        note: c.note.as_deref(),
843        progress_done,
844        progress_total,
845    }
846}
847
848#[derive(Debug, Serialize)]
849struct StatusResponse<'a> {
850    project_root: &'a str,
851    graph_index: ComponentStatus<'a>,
852    bm25_index: ComponentStatus<'a>,
853    /// Dense embedding index built after BM25.  "idle" means the ONNX model
854    /// has not been downloaded yet or the embeddings feature was not compiled
855    /// in; "ready" means embeddings are persisted and search will use them.
856    semantic_index: ComponentStatus<'a>,
857    disk: DiskStatusAll,
858    /// Active corpus filter summary (#735). Omitted for the unfiltered
859    /// default, keeping default output byte-identical.
860    #[serde(skip_serializing_if = "Option::is_none")]
861    index_filters: Option<String>,
862}
863
864#[derive(Debug, Serialize, Default)]
865pub struct DiskStatus {
866    pub exists: bool,
867    pub size_bytes: Option<u64>,
868    pub file_count: Option<u64>,
869    pub modified_at: Option<String>,
870}
871
872#[derive(Debug, Serialize, Default)]
873pub struct DiskStatusAll {
874    pub graph_index: DiskStatus,
875    pub bm25_index: DiskStatus,
876    pub code_graph: DiskStatus,
877    /// On-disk embedding index (`embeddings.bin`).  Present when dense search
878    /// has been built at least once; absent when the model is not downloaded
879    /// yet or embeddings are disabled by config.
880    pub semantic_index: DiskStatus,
881}
882
883fn disk_status_for_graph(project_root: &str) -> DiskStatus {
884    // #696 C4: the property graph is the sole store. The logical graph-index
885    // view (file count) is sized/timed by `graph.meta.json`, which the mirror
886    // stamps on every build; `disk_status_for_code_graph` reports the raw
887    // SQLite store (nodes, graph.db) as a distinct facet.
888    let Some(dir) = graph_index::ProjectIndex::index_dir(project_root) else {
889        return DiskStatus::default();
890    };
891    let meta_file = dir.join("graph.meta.json");
892    if !meta_file.exists() {
893        return DiskStatus::default();
894    }
895    let meta = std::fs::metadata(&meta_file).ok();
896    let file_count =
897        graph_index::ProjectIndex::load(project_root).map(|idx| idx.files.len() as u64);
898    DiskStatus {
899        exists: true,
900        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
901        file_count,
902        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
903    }
904}
905
906fn disk_status_for_bm25(project_root: &str) -> DiskStatus {
907    let root = Path::new(project_root);
908    let path = BM25Index::index_file_path(root);
909    if !path.exists() {
910        return DiskStatus::default();
911    }
912    let meta = std::fs::metadata(&path).ok();
913    DiskStatus {
914        exists: true,
915        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
916        file_count: None,
917        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
918    }
919}
920
921fn disk_status_for_code_graph(project_root: &str) -> DiskStatus {
922    let dir = crate::core::property_graph::graph_dir(project_root);
923    let db_path = dir.join("graph.db");
924    if !db_path.exists() {
925        return DiskStatus::default();
926    }
927    let meta = std::fs::metadata(&db_path).ok();
928    let node_count = crate::core::property_graph::CodeGraph::open(project_root)
929        .ok()
930        .and_then(|g| {
931            g.connection()
932                .query_row("SELECT count(*) FROM nodes", [], |r| r.get::<_, i64>(0))
933                .ok()
934                .map(|c| c as u64)
935        });
936    DiskStatus {
937        exists: true,
938        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
939        file_count: node_count,
940        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
941    }
942}
943
944fn format_time(t: SystemTime) -> String {
945    let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
946    let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
947    dt.map_or_else(
948        || format!("{secs}"),
949        |d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
950    )
951}
952
953pub fn disk_status_for_semantic(project_root: &str) -> DiskStatus {
954    let root = Path::new(project_root);
955    let dir = crate::core::index_namespace::vectors_dir(root);
956    let bin_path = dir.join("embeddings.bin");
957    if !bin_path.exists() {
958        return DiskStatus::default();
959    }
960    let meta = std::fs::metadata(&bin_path).ok();
961    DiskStatus {
962        exists: true,
963        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
964        file_count: None,
965        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
966    }
967}
968
969pub fn disk_status(project_root: &str) -> DiskStatusAll {
970    DiskStatusAll {
971        graph_index: disk_status_for_graph(project_root),
972        bm25_index: disk_status_for_bm25(project_root),
973        code_graph: disk_status_for_code_graph(project_root),
974        semantic_index: disk_status_for_semantic(project_root),
975    }
976}
977
978pub fn status_json(project_root: &str) -> String {
979    // Compute disk status first — may do SQLite I/O and must NOT hold L2
980    // (per-project Mutex) while doing so, or the background index worker
981    // cannot call finish_ok / set worker_running = false (#deadlock).
982    let disk = disk_status(project_root);
983    let state = entry_for(project_root);
984    let s = state
985        .lock()
986        .unwrap_or_else(std::sync::PoisonError::into_inner);
987    let graph_p = crate::core::index_progress::get(
988        project_root,
989        crate::core::index_progress::IndexComponent::Graph,
990    );
991    let bm25_p = crate::core::index_progress::get(
992        project_root,
993        crate::core::index_progress::IndexComponent::Bm25,
994    );
995    let semantic_p = crate::core::index_progress::get(
996        project_root,
997        crate::core::index_progress::IndexComponent::Semantic,
998    );
999    let res = StatusResponse {
1000        project_root,
1001        graph_index: component_status(&s.graph, graph_p),
1002        bm25_index: component_status(&s.bm25, bm25_p),
1003        semantic_index: component_status(&s.semantic, semantic_p),
1004        disk,
1005        index_filters: crate::core::index_filter::IndexFileFilter::effective().summary(),
1006    };
1007    serde_json::to_string(&res).unwrap_or_else(|_| "{}".to_string())
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013
1014    #[test]
1015    fn progress_view_graph_bm25_active_logic() {
1016        let idle = SlotProgress::default();
1017        let mut v = ProgressView {
1018            worker_running: true,
1019            graph_run_done: false,
1020            bm25_run_done: false,
1021            graph: idle,
1022            bm25: idle,
1023            semantic: idle,
1024        };
1025        assert!(v.graph_bm25_active(), "claimed, phases not done");
1026        v.graph_run_done = true;
1027        assert!(v.graph_bm25_active(), "graph done, bm25 pending");
1028        v.bm25_run_done = true;
1029        assert!(
1030            !v.graph_bm25_active(),
1031            "both phases done — search pre-warm must not keep wait open"
1032        );
1033        v.worker_running = false;
1034        assert!(!v.graph_bm25_active());
1035        // Idle project (never claimed this process)
1036        v.graph_run_done = true;
1037        v.bm25_run_done = true;
1038        assert!(!v.graph_bm25_active());
1039    }
1040
1041    #[test]
1042    fn status_json_is_valid_json() {
1043        let s = status_json("/tmp");
1044        let _: serde_json::Value = serde_json::from_str(&s).unwrap();
1045    }
1046
1047    #[test]
1048    fn background_build_phases_are_serialized_with_reclamation() {
1049        let events = std::cell::RefCell::new(Vec::new());
1050        run_memory_bounded_phases(
1051            || events.borrow_mut().push("graph"),
1052            || events.borrow_mut().push("bm25"),
1053            || events.borrow_mut().push("search"),
1054            || events.borrow_mut().push("reclaim"),
1055        );
1056        assert_eq!(
1057            events.into_inner(),
1058            ["graph", "reclaim", "bm25", "reclaim", "search"]
1059        );
1060    }
1061
1062    #[test]
1063    fn warm_need_classifies_tools() {
1064        // Lightweight tools must never trigger a project scan (#152).
1065        for light in [
1066            "ctx_read",
1067            "ctx_shell",
1068            "ctx_tree",
1069            "ctx_knowledge",
1070            "unknown_tool",
1071        ] {
1072            assert_eq!(warm_need_for_tool(light), WarmNeed::None, "{light}");
1073        }
1074        // ctx_search only needs the cheap trigram index.
1075        assert_eq!(warm_need_for_tool("ctx_search"), WarmNeed::Search);
1076        for heavy in [
1077            "ctx_graph",
1078            "ctx_callgraph",
1079            "ctx_routes",
1080            "ctx_repomap",
1081            "ctx_impact",
1082            "ctx_artifacts",
1083            "ctx_semantic_search",
1084            "ctx_provider",
1085            "ctx_compose",
1086            "ctx_explore",
1087            "ctx_review",
1088        ] {
1089            assert_eq!(warm_need_for_tool(heavy), WarmNeed::Heavy, "{heavy}");
1090        }
1091    }
1092
1093    #[test]
1094    fn ensure_warm_lightweight_and_search_never_signal_first_warm() {
1095        assert!(!ensure_warm_for_tool("", "ctx_graph"));
1096        let tmp = tempfile::tempdir().unwrap();
1097        let root = tmp.path().to_string_lossy().to_string();
1098        assert!(!ensure_warm_for_tool(&root, "ctx_read"));
1099        assert!(!ensure_warm_for_tool(&root, "ctx_search"));
1100    }
1101
1102    #[test]
1103    fn ensure_warm_heavy_is_once_per_root() {
1104        // The first heavy pre-warm signals `true` (so the caller warms extra
1105        // roots once); every subsequent call is a no-op `false`, preventing a
1106        // rebuild-on-every-dispatch storm.
1107        let tmp = tempfile::tempdir().unwrap();
1108        let root = tmp.path().to_string_lossy().to_string();
1109        assert!(
1110            ensure_warm_for_tool(&root, "ctx_callgraph"),
1111            "first heavy warm must signal true"
1112        );
1113        assert!(
1114            !ensure_warm_for_tool(&root, "ctx_callgraph"),
1115            "second heavy warm must be deduped to false"
1116        );
1117        assert!(
1118            !ensure_warm_for_tool(&root, "ctx_semantic_search"),
1119            "any later heavy tool on the same root is also deduped"
1120        );
1121    }
1122
1123    #[test]
1124    fn build_note_persisted_reports_size() {
1125        let note = bm25_build_note(
1126            42,
1127            &Ok(crate::core::bm25_index::SaveOutcome::Persisted {
1128                compressed_bytes: 3 * 1024 * 1024,
1129            }),
1130        );
1131        assert!(
1132            note.contains("42 chunks"),
1133            "note should report chunk count: {note}"
1134        );
1135        assert!(
1136            note.contains("persisted"),
1137            "note should report persistence: {note}"
1138        );
1139    }
1140
1141    #[test]
1142    fn build_note_too_large_carries_remedy() {
1143        let note = bm25_build_note(
1144            1000,
1145            &Ok(crate::core::bm25_index::SaveOutcome::SkippedTooLarge {
1146                compressed_bytes: 600 * 1024 * 1024,
1147                limit_bytes: 512 * 1024 * 1024,
1148            }),
1149        );
1150        assert!(
1151            note.contains("NOT persisted"),
1152            "must flag non-persistence: {note}"
1153        );
1154        assert!(
1155            note.contains("LEAN_CTX_BM25_MAX_CACHE_MB") && note.contains("reindex"),
1156            "too-large note must carry an actionable remedy: {note}"
1157        );
1158    }
1159
1160    #[test]
1161    fn build_note_persist_error_is_reported() {
1162        let note = bm25_build_note(7, &Err(std::io::Error::other("disk full")));
1163        assert!(note.contains("persisting failed"), "note: {note}");
1164        assert!(
1165            note.contains("disk full"),
1166            "note should include the io error: {note}"
1167        );
1168    }
1169
1170    #[test]
1171    fn bm25_summary_unknown_project_is_idle() {
1172        let tmp = tempfile::tempdir().unwrap();
1173        let summary = bm25_summary(tmp.path().to_string_lossy().as_ref());
1174        assert_eq!(summary.state, "idle");
1175        assert!(summary.note.is_none());
1176        assert!(summary.last_error.is_none());
1177    }
1178
1179    #[test]
1180    fn extra_roots_skips_subdirs_of_primary() {
1181        let tmp = tempfile::tempdir().unwrap();
1182        let primary = tmp.path().join("primary");
1183        std::fs::create_dir_all(&primary).unwrap();
1184        let sub = primary.join("subdir");
1185        std::fs::create_dir_all(&sub).unwrap();
1186        let external = tmp.path().join("external");
1187        std::fs::create_dir_all(&external).unwrap();
1188
1189        let primary_str = primary.to_string_lossy().to_string();
1190        let extra = vec![
1191            sub.to_string_lossy().to_string(),
1192            external.to_string_lossy().to_string(),
1193        ];
1194
1195        // Should not panic; subdirs are skipped, external is attempted
1196        ensure_extra_roots_background(&primary_str, &extra);
1197    }
1198
1199    #[test]
1200    fn extra_roots_caps_at_max() {
1201        let tmp = tempfile::tempdir().unwrap();
1202        let primary = tmp.path().join("primary");
1203        std::fs::create_dir_all(&primary).unwrap();
1204
1205        let mut extra = Vec::new();
1206        for i in 0..20 {
1207            let d = tmp.path().join(format!("ext-{i}"));
1208            std::fs::create_dir_all(&d).unwrap();
1209            extra.push(d.to_string_lossy().to_string());
1210        }
1211
1212        let primary_str = primary.to_string_lossy().to_string();
1213        // Should not spawn more than MAX_EXTRA_ROOT_BUILDS threads
1214        ensure_extra_roots_background(&primary_str, &extra);
1215    }
1216
1217    #[test]
1218    fn bm25_index_lock_name_is_per_repo_and_distinct_from_graph() {
1219        let a = bm25_index_lock_name(Path::new("/tmp/repo-a"));
1220        let b = bm25_index_lock_name(Path::new("/tmp/repo-b"));
1221        assert!(a.starts_with("bm25-idx-"), "unexpected lock name: {a}");
1222        assert_ne!(a, b, "lock name must be per-repo");
1223        // Stable for the same repo across calls.
1224        assert_eq!(a, bm25_index_lock_name(Path::new("/tmp/repo-a")));
1225        // Must NOT collide with the graph lock for the same repo, or the two
1226        // builds would serialize against each other unnecessarily.
1227        let graph = format!(
1228            "graph-idx-{}",
1229            &crate::core::index_namespace::namespace_hash(Path::new("/tmp/repo-a"))[..8]
1230        );
1231        assert_ne!(a, graph, "bm25 and graph locks must be independent");
1232    }
1233}