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}