Skip to main content

harn_hostlib/code_index/
mod.rs

1//! Code index host capability.
2//!
3//! Deterministic trigram/word index plus live workspace state (agent
4//! registry, advisory locks, append-only version log, file id assignment,
5//! cached reads). The capability owns one [`SharedIndex`] cell per
6//! instance; cloning the capability shares state with every Harn VM that
7//! has been wired against it.
8//!
9//! Surface — every builtin is locked by `schemas/code_index/<method>.json`:
10//!
11//! ### Workspace queries (the original 5)
12//!
13//! | Builtin                          | What it does                                           |
14//! |----------------------------------|--------------------------------------------------------|
15//! | `hostlib_code_index_query`       | Trigram-accelerated literal substring search.          |
16//! | `hostlib_code_index_rebuild`     | Walk a workspace and (re)build the in-memory index.    |
17//! | `hostlib_code_index_stats`       | Count files/trigrams/words + last rebuild timestamp.   |
18//! | `hostlib_code_index_imports_for` | Imports declared by a single file (with resolutions).  |
19//! | `hostlib_code_index_importers_of`| Reverse lookup: who imports the given module/path?     |
20//!
21//! ### Live workspace state (added in #776)
22//!
23//! - **Agents**: `agent_register`, `agent_heartbeat`, `agent_unregister`,
24//!   `current_agent_id`, `status`.
25//! - **Locks**: `lock_try`, `lock_release`.
26//! - **Change log**: `current_seq`, `changes_since`, `version_record`.
27//! - **File table**: `path_to_id`, `id_to_path`, `file_ids`, `file_meta`,
28//!   `file_hash`.
29//! - **Cached reads**: `read_range`, `reindex_file`, `trigram_query`,
30//!   `extract_trigrams`, `word_get`, `deps_get`, `outline_get`.
31//!
32//! ### Typed symbol graph (added in #2434)
33//!
34//! - **`cypher`**: read-only Cypher executor over the typed graph
35//!   ([`SymbolGraph`]) — `MATCH ... WHERE ... RETURN` with typed
36//!   nodes (Function|Type|Field|EnumCase|Module|Import|CallSite|Macro), typed edges
37//!   (CALLS|REFS|IMPORTS|CONTAINS|OVERRIDES, plus `_BY` inverses),
38//!   and variable-length hops up to depth 4.
39//! - **`branch_overlay`**: per-branch CDC overlay that layers a delta
40//!   on top of the base graph; reuses ≥95% of the main index in
41//!   storage/CPU for untouched files. See [`BranchOverlay`].
42//! - **`freshness`**: per-file hash + mtime comparison against the
43//!   indexed snapshot; consumers detect staleness without forcing a
44//!   rebuild.
45//! - **`repo_map`**: personalized PageRank over the typed graph, rendered
46//!   as a token-budgeted symbol map for agent grounding.
47//!
48//! ### Cross-file safe rename (added in #2508)
49//!
50//! - **`rename_symbol`**: rewrite a symbol across `file | module |
51//!   workspace` using the typed graph for symbol resolution and
52//!   tree-sitter identifier kinds for safe text spans. Detects
53//!   `new_name` shadowing in any rewritten file and aborts before any
54//!   write. Routes through staged-fs (#1722) when a `session_id` is
55//!   supplied so all touched files succeed or none do.
56//!
57//! ## Concurrency model
58//!
59//! All ops serialise through a single `Arc<Mutex<Option<IndexState>>>` so
60//! the IDE editor, eval, and live agent all see one consistent view. The
61//! capability is `Send + Sync` so embedders can share it across threads,
62//! but the mutex still serialises actual work.
63
64mod agents;
65mod builtins;
66mod cypher;
67mod file_table;
68mod graph;
69mod imports;
70mod overlay;
71mod readonly;
72mod rename;
73mod repo_map;
74mod snapshot;
75mod state;
76mod symbol_graph;
77mod trigram;
78mod versions;
79mod walker;
80mod warm;
81mod words;
82
83use std::path::Path;
84use std::sync::{Arc, Mutex};
85
86use harn_vm::VmValue;
87
88use crate::error::HostlibError;
89use crate::registry::{BuiltinRegistry, HostlibCapability, RegisteredBuiltin, SyncHandler};
90
91pub use agents::{AgentId, AgentInfo, AgentRegistry, AgentState, RegistryConfig};
92pub use builtins::SharedIndex;
93pub use cypher::{CypherError, CypherRow, CypherValue};
94pub use file_table::{FileId, IndexedFile, IndexedSymbol};
95pub use graph::DepGraph;
96pub use overlay::{BranchOverlay, OverlayState};
97pub use readonly::ReadonlyRoots;
98pub use snapshot::{CodeIndexSnapshot, SnapshotMeta};
99pub use state::{BuildOutcome, IndexState};
100pub use symbol_graph::{Edge, EdgeKind, Node, NodeId, NodeKind, SymbolGraph};
101pub use trigram::TrigramIndex;
102pub use versions::{ChangeRecord, EditOp, VersionEntry, VersionLog, HISTORY_LIMIT};
103pub use warm::SessionWarmOutcome;
104pub use words::{WordHit, WordIndex};
105
106/// Code-index capability handle.
107///
108/// Holds the [`SharedIndex`] cell behind an `Arc<Mutex<...>>`; cloning
109/// the capability shares state. The capability also threads a
110/// `current_agent_id` slot used by the `current_agent_id` host builtin —
111/// embedders update this slot from the request-handling layer so each
112/// host call surfaces the right agent identity to scripts.
113#[derive(Clone, Default)]
114pub struct CodeIndexCapability {
115    index: SharedIndex,
116    /// Additive, read-only secondary roots (issue #2403 follow-up). Live
117    /// beside the primary slot; query/read_range merge them in but no
118    /// mutating builtin ever touches them, so indexing a dependency root
119    /// never clobbers the project index.
120    readonly: ReadonlyRoots,
121    current_agent: Arc<Mutex<Option<AgentId>>>,
122    /// Single-flight gate shared by [`Self::warm_session`] and sync rebuild.
123    warm: Arc<warm::WarmCoordinator>,
124}
125
126impl CodeIndexCapability {
127    /// Create a capability with an empty workspace slot. The first
128    /// `hostlib_code_index_rebuild` call populates it.
129    pub fn new() -> Self {
130        Self {
131            index: Arc::new(Mutex::new(None)),
132            readonly: Arc::new(Mutex::new(Vec::new())),
133            current_agent: Arc::new(Mutex::new(None)),
134            warm: Arc::new(warm::WarmCoordinator::default()),
135        }
136    }
137
138    /// Borrow the underlying shared cell. Useful for tests and embedders
139    /// that want to introspect index state without going through the
140    /// builtins.
141    pub fn shared(&self) -> SharedIndex {
142        self.index.clone()
143    }
144
145    /// Borrow the current-agent slot. Embedders bind this slot before
146    /// dispatching a host call so that `current_agent_id` returns the
147    /// right value to the script.
148    pub fn current_agent_slot(&self) -> Arc<Mutex<Option<AgentId>>> {
149        self.current_agent.clone()
150    }
151
152    /// Convenience: set the current agent id. Returns the previous value
153    /// (so callers can restore on completion if they bind per-call).
154    pub fn set_current_agent(&self, id: Option<AgentId>) -> Option<AgentId> {
155        let mut guard = self.current_agent.lock().expect("current_agent poisoned");
156        std::mem::replace(&mut *guard, id)
157    }
158
159    /// Restore from a previously saved snapshot at the path returned by
160    /// [`CodeIndexSnapshot::path_for`]. After restoring, runs
161    /// [`IndexState::reap_after_recovery`] so stale agent records and
162    /// locks are dropped before the daemon serves traffic.
163    ///
164    /// Returns `true` on a successful restore, `false` if no snapshot
165    /// existed (or the format was unrecognised). Errors propagate I/O
166    /// problems verbatim so callers can decide whether to fall back to
167    /// `rebuild`.
168    pub fn restore_from_disk(&self, workspace_root: &Path) -> std::io::Result<bool> {
169        match CodeIndexSnapshot::load(workspace_root)? {
170            Some(snap) => {
171                let mut state = IndexState::from_snapshot(snap);
172                state.reap_after_recovery(state::now_unix_ms());
173                let mut guard = self.index.lock().expect("code_index mutex poisoned");
174                *guard = Some(state);
175                Ok(true)
176            }
177            None => Ok(false),
178        }
179    }
180
181    /// Persist the current in-memory state to the path returned by
182    /// [`CodeIndexSnapshot::path_for`]. Returns `Ok(false)` when the
183    /// capability is empty (nothing to save).
184    pub fn persist_to_disk(&self) -> std::io::Result<bool> {
185        let snap = {
186            let guard = self.index.lock().expect("code_index mutex poisoned");
187            guard
188                .as_ref()
189                .map(|state| (state.snapshot(), state.root.clone()))
190        };
191        match snap {
192            Some((snap, root)) => {
193                snap.save(&root)?;
194                Ok(true)
195            }
196            None => Ok(false),
197        }
198    }
199}
200
201impl HostlibCapability for CodeIndexCapability {
202    fn module_name(&self) -> &'static str {
203        "code_index"
204    }
205
206    fn register_builtins(&self, registry: &mut BuiltinRegistry) {
207        // Workspace queries (original 5). `query` and `read_range` merge in
208        // the read-only secondary roots (issue #2403 follow-up), so they
209        // capture both the primary and the read-only cells.
210        {
211            let index = self.index.clone();
212            let readonly = self.readonly.clone();
213            let handler: SyncHandler =
214                Arc::new(move |args| builtins::run_query_merged(&index, Some(&readonly), args));
215            registry.register(RegisteredBuiltin {
216                name: builtins::BUILTIN_QUERY,
217                module: "code_index",
218                method: "query",
219                handler,
220            });
221        }
222        {
223            let index = self.index.clone();
224            let warm = self.warm.clone();
225            let handler: SyncHandler =
226                Arc::new(move |args| warm::run_rebuild_single_flight(&index, &warm, args));
227            registry.register(RegisteredBuiltin {
228                name: builtins::BUILTIN_REBUILD,
229                module: "code_index",
230                method: "rebuild",
231                handler,
232            });
233        }
234        register(
235            registry,
236            self.index.clone(),
237            builtins::BUILTIN_STATS,
238            "stats",
239            builtins::run_stats,
240        );
241        register(
242            registry,
243            self.index.clone(),
244            builtins::BUILTIN_IMPORTS_FOR,
245            "imports_for",
246            builtins::run_imports_for,
247        );
248        register(
249            registry,
250            self.index.clone(),
251            builtins::BUILTIN_IMPORTERS_OF,
252            "importers_of",
253            builtins::run_importers_of,
254        );
255
256        // Additive read-only secondary roots (issue #2403 follow-up).
257        // Captures the read-only cell directly — it never touches the
258        // primary index slot.
259        {
260            let readonly = self.readonly.clone();
261            let handler: SyncHandler =
262                Arc::new(move |args| readonly::run_add_readonly_roots(&readonly, args));
263            registry.register(RegisteredBuiltin {
264                name: readonly::BUILTIN_ADD_READONLY_ROOTS,
265                module: "code_index",
266                method: "add_readonly_roots",
267                handler,
268            });
269        }
270
271        // File table accessors.
272        register(
273            registry,
274            self.index.clone(),
275            builtins::BUILTIN_PATH_TO_ID,
276            "path_to_id",
277            builtins::run_path_to_id,
278        );
279        register(
280            registry,
281            self.index.clone(),
282            builtins::BUILTIN_ID_TO_PATH,
283            "id_to_path",
284            builtins::run_id_to_path,
285        );
286        register(
287            registry,
288            self.index.clone(),
289            builtins::BUILTIN_FILE_IDS,
290            "file_ids",
291            builtins::run_file_ids,
292        );
293        register(
294            registry,
295            self.index.clone(),
296            builtins::BUILTIN_FILE_META,
297            "file_meta",
298            builtins::run_file_meta,
299        );
300        register(
301            registry,
302            self.index.clone(),
303            builtins::BUILTIN_FILE_HASH,
304            "file_hash",
305            builtins::run_file_hash,
306        );
307        register(
308            registry,
309            self.index.clone(),
310            builtins::BUILTIN_FILE_HASH_SNAPSHOT,
311            "file_hash_snapshot",
312            builtins::run_file_hash_snapshot,
313        );
314
315        // Cached read paths. `read_range` falls back to the read-only
316        // secondary roots (issue #2403 follow-up) so a symbol discovered in
317        // a dependency root can be read back.
318        {
319            let index = self.index.clone();
320            let readonly = self.readonly.clone();
321            let handler: SyncHandler = Arc::new(move |args| {
322                builtins::run_read_range_merged(&index, Some(&readonly), args)
323            });
324            registry.register(RegisteredBuiltin {
325                name: builtins::BUILTIN_READ_RANGE,
326                module: "code_index",
327                method: "read_range",
328                handler,
329            });
330        }
331        register(
332            registry,
333            self.index.clone(),
334            builtins::BUILTIN_REINDEX_FILE,
335            "reindex_file",
336            builtins::run_reindex_file,
337        );
338        register(
339            registry,
340            self.index.clone(),
341            builtins::BUILTIN_TRIGRAM_QUERY,
342            "trigram_query",
343            builtins::run_trigram_query,
344        );
345        register(
346            registry,
347            self.index.clone(),
348            builtins::BUILTIN_EXTRACT_TRIGRAMS,
349            "extract_trigrams",
350            builtins::run_extract_trigrams,
351        );
352        register(
353            registry,
354            self.index.clone(),
355            builtins::BUILTIN_WORD_GET,
356            "word_get",
357            builtins::run_word_get,
358        );
359        register(
360            registry,
361            self.index.clone(),
362            builtins::BUILTIN_DEPS_GET,
363            "deps_get",
364            builtins::run_deps_get,
365        );
366        register(
367            registry,
368            self.index.clone(),
369            builtins::BUILTIN_OUTLINE_GET,
370            "outline_get",
371            builtins::run_outline_get,
372        );
373
374        // Change log.
375        register(
376            registry,
377            self.index.clone(),
378            builtins::BUILTIN_CURRENT_SEQ,
379            "current_seq",
380            builtins::run_current_seq,
381        );
382        register(
383            registry,
384            self.index.clone(),
385            builtins::BUILTIN_CHANGES_SINCE,
386            "changes_since",
387            builtins::run_changes_since,
388        );
389        register(
390            registry,
391            self.index.clone(),
392            builtins::BUILTIN_VERSION_RECORD,
393            "version_record",
394            builtins::run_version_record,
395        );
396
397        // Agent registry + locks.
398        register(
399            registry,
400            self.index.clone(),
401            builtins::BUILTIN_AGENT_REGISTER,
402            "agent_register",
403            builtins::run_agent_register,
404        );
405        register(
406            registry,
407            self.index.clone(),
408            builtins::BUILTIN_AGENT_HEARTBEAT,
409            "agent_heartbeat",
410            builtins::run_agent_heartbeat,
411        );
412        register(
413            registry,
414            self.index.clone(),
415            builtins::BUILTIN_AGENT_UNREGISTER,
416            "agent_unregister",
417            builtins::run_agent_unregister,
418        );
419        register(
420            registry,
421            self.index.clone(),
422            builtins::BUILTIN_LOCK_TRY,
423            "lock_try",
424            builtins::run_lock_try,
425        );
426        register(
427            registry,
428            self.index.clone(),
429            builtins::BUILTIN_LOCK_RELEASE,
430            "lock_release",
431            builtins::run_lock_release,
432        );
433        register(
434            registry,
435            self.index.clone(),
436            builtins::BUILTIN_STATUS,
437            "status",
438            builtins::run_status,
439        );
440
441        // `current_agent_id` is the only handler that reads from the
442        // capability's per-call `current_agent` slot rather than the
443        // index state, so it gets its own closure.
444        let slot = self.current_agent.clone();
445        let handler: SyncHandler =
446            Arc::new(move |args| builtins::run_current_agent_id(&slot, args));
447        registry.register(RegisteredBuiltin {
448            name: builtins::BUILTIN_CURRENT_AGENT_ID,
449            module: "code_index",
450            method: "current_agent_id",
451            handler,
452        });
453
454        // Typed symbol graph builtins (issue #2434).
455        register(
456            registry,
457            self.index.clone(),
458            builtins::BUILTIN_CYPHER,
459            "cypher",
460            builtins::run_cypher,
461        );
462        register(
463            registry,
464            self.index.clone(),
465            repo_map::BUILTIN,
466            "repo_map",
467            repo_map::run,
468        );
469        register(
470            registry,
471            self.index.clone(),
472            builtins::BUILTIN_BRANCH_OVERLAY,
473            "branch_overlay",
474            builtins::run_branch_overlay,
475        );
476        register(
477            registry,
478            self.index.clone(),
479            builtins::BUILTIN_FRESHNESS,
480            "freshness",
481            builtins::run_freshness,
482        );
483
484        // Cross-file safe rename (issue #2508). Builds on the typed
485        // symbol graph (#2434) and routes writes through staged-fs
486        // (#1722) so all touched files succeed or none do.
487        register(
488            registry,
489            self.index.clone(),
490            rename::BUILTIN,
491            "rename_symbol",
492            rename::run,
493        );
494    }
495}
496
497/// Programmatic entry point for callers that need to compose
498/// `rename_symbol` with another hostlib capability while sharing the
499/// same in-memory code-index state.
500pub(crate) fn run_rename_symbol(
501    index: &SharedIndex,
502    args: &[VmValue],
503) -> Result<VmValue, HostlibError> {
504    rename::run(index, args)
505}
506
507fn register(
508    registry: &mut BuiltinRegistry,
509    index: SharedIndex,
510    name: &'static str,
511    method: &'static str,
512    runner: fn(&SharedIndex, &[VmValue]) -> Result<VmValue, HostlibError>,
513) {
514    let captured = index;
515    let handler: SyncHandler = Arc::new(move |args| runner(&captured, args));
516    registry.register(RegisteredBuiltin {
517        name,
518        module: "code_index",
519        method,
520        handler,
521    });
522}