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