Skip to main content

engram_core/
server.rs

1use std::path::{Path, PathBuf};
2use std::sync::{Arc, Mutex, RwLock};
3
4use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
5use tokio::net::UnixListener;
6use tokio::signal::unix::{SignalKind, signal};
7
8use engram_embeddings::Embedder;
9use engram_llm_client::{EmbeddingProvider, TextGenerator};
10use engram_router::Router;
11use engram_storage::Database;
12
13use crate::config::{self, Config};
14use crate::dispatch;
15use crate::error::CoreError;
16use crate::indexes::IndexSet;
17use crate::lock_helpers;
18use crate::protocol::{JsonRequest, JsonResponse};
19
20const PROJECT_DB_RELATIVE: &str = ".engram/engram.db";
21const PROJECT_SOCKET_RELATIVE: &str = ".engram/engram.sock";
22
23const ROUTER_DEFAULT_ALPHA: f32 = 0.1;
24const ROUTER_DEFAULT_EPSILON: f32 = 0.15;
25
26pub struct ServerState {
27    pub database: Mutex<Database>,
28    pub indexes: RwLock<IndexSet>,
29    pub embedder: Embedder,
30    pub router: Mutex<Router>,
31    pub config: Config,
32    pub database_path: String,
33    pub embedding_provider: Arc<dyn EmbeddingProvider + Send + Sync>,
34    pub text_generator: Option<Arc<dyn TextGenerator + Send + Sync>>,
35}
36
37pub async fn run(config: Config) -> Result<(), CoreError> {
38    let cwd = std::env::current_dir()
39        .map_err(|error| CoreError::SocketError(format!("cwd unavailable: {error}")))?;
40    let home = home_dir_or_error()?;
41    let project_dir = config::resolve_project_dir(&cwd, None)?;
42    // See execute_command in main.rs for why this construction has to run on
43    // the blocking pool. reqwest::blocking::ClientBuilder::build internally
44    // spins up and drops a current-thread tokio runtime; doing that on the
45    // outer multi-threaded runtime's worker panics.
46    let state = {
47        let config = config.clone();
48        let project_dir = project_dir.clone();
49        let home = home.clone();
50        tokio::task::spawn_blocking(move || initialize_state(&config, &project_dir, &home))
51            .await
52            .map_err(|error| CoreError::SocketError(error.to_string()))??
53    };
54    {
55        let database = lock_helpers::lock_db(&state);
56        crate::migrations::embedding_model_v1::check(
57            &database,
58            state.embedding_provider.model_name(),
59        )?;
60    }
61    warn_missing_api_keys(&config);
62    let shared_state = Arc::new(state);
63    let socket_path = resolve_socket_path(&project_dir, &config)?;
64    cleanup_stale_socket(&socket_path);
65    let listener = bind_listener(&socket_path)?;
66    accept_loop(listener, shared_state).await
67}
68
69pub fn check_legacy_database(project_dir: &Path, home_dir: &Path) -> Result<(), CoreError> {
70    let project_db = project_dir.join(PROJECT_DB_RELATIVE);
71    let home_db = home_dir.join(PROJECT_DB_RELATIVE);
72    if !project_db.exists() && home_db.exists() {
73        return Err(CoreError::LegacyDatabaseDetected {
74            legacy_path: home_db.to_string_lossy().to_string(),
75            project_path: project_dir.to_string_lossy().to_string(),
76        });
77    }
78    Ok(())
79}
80
81pub(crate) fn initialize_state(
82    config: &Config,
83    project_dir: &Path,
84    home_dir: &Path,
85) -> Result<ServerState, CoreError> {
86    crate::config::validate_dedup_threshold(config.deduplication.threshold)?;
87    crate::config::validate_search_config(&config.search)?;
88    check_legacy_database(project_dir, home_dir)?;
89    let database_path = resolve_database_path(project_dir);
90    let database = Database::open(&database_path)?;
91    crate::migrations::run_pending(&database)?;
92    let embedding_provider: Arc<dyn EmbeddingProvider + Send + Sync> =
93        Arc::from(config.build_embedding_provider()?);
94    let text_generator: Option<Arc<dyn TextGenerator + Send + Sync>> =
95        match config.build_text_generator() {
96            Ok(generator) => Some(Arc::from(generator)),
97            Err(error) => {
98                eprintln!("warning: text generator unavailable, running in degraded mode: {error}");
99                None
100            }
101        };
102    let hnsw_config = config.clone();
103    let indexes = crate::persistence::load_or_rebuild(
104        &resolve_index_directory(&database_path),
105        &database,
106        || hnsw_config.build_hnsw_params(),
107    )?;
108    let embedder = Embedder::new(config.embedding.hyde_threshold);
109    let router = Router::new(ROUTER_DEFAULT_ALPHA, ROUTER_DEFAULT_EPSILON);
110    Ok(ServerState {
111        database: Mutex::new(database),
112        indexes: RwLock::new(indexes),
113        embedder,
114        router: Mutex::new(router),
115        config: config.clone(),
116        database_path,
117        embedding_provider,
118        text_generator,
119    })
120}
121
122fn resolve_database_path(project_dir: &Path) -> String {
123    if let Ok(value) = std::env::var("ENGRAM_DB_PATH") {
124        return value;
125    }
126    project_dir
127        .join(PROJECT_DB_RELATIVE)
128        .to_string_lossy()
129        .to_string()
130}
131
132fn resolve_socket_path(project_dir: &Path, config: &Config) -> Result<String, CoreError> {
133    if let Ok(value) = std::env::var("ENGRAM_SOCKET_PATH") {
134        return Ok(value);
135    }
136    // Prefer project socket when the `.engram/` dir exists (walk-up already validated this).
137    if project_dir.join(".engram").is_dir() {
138        return Ok(project_dir
139            .join(PROJECT_SOCKET_RELATIVE)
140            .to_string_lossy()
141            .to_string());
142    }
143    match config.server.socket_path.as_deref() {
144        Some(fallback) => Ok(expand_tilde(fallback)),
145        None => Err(CoreError::SocketError(
146            "no socket path available: no project .engram/ marker found, \
147             ENGRAM_SOCKET_PATH not set, and server.socket_path not configured"
148                .into(),
149        )),
150    }
151}
152
153fn home_dir_or_error() -> Result<PathBuf, CoreError> {
154    match std::env::var("HOME") {
155        Ok(value) if !value.is_empty() => Ok(PathBuf::from(value)),
156        _ => Err(CoreError::InitFailed(
157            "HOME environment variable not set".into(),
158        )),
159    }
160}
161
162pub(crate) fn resolve_index_directory(database_path: &str) -> String {
163    std::path::Path::new(database_path)
164        .parent()
165        .map(|parent| parent.to_string_lossy().to_string())
166        .unwrap_or_else(|| ".".to_string())
167}
168
169fn cleanup_stale_socket(socket_path: &str) {
170    let _ = std::fs::remove_file(socket_path);
171}
172
173fn bind_listener(socket_path: &str) -> Result<UnixListener, CoreError> {
174    if let Some(parent) = std::path::Path::new(socket_path).parent() {
175        std::fs::create_dir_all(parent)
176            .map_err(|error| CoreError::SocketError(error.to_string()))?;
177    }
178    UnixListener::bind(socket_path).map_err(|error| CoreError::SocketError(error.to_string()))
179}
180
181async fn accept_loop(listener: UnixListener, state: Arc<ServerState>) -> Result<(), CoreError> {
182    let mut sigterm =
183        signal(SignalKind::terminate()).map_err(|e| CoreError::SocketError(e.to_string()))?;
184    spawn_background_reindex(Arc::clone(&state));
185    loop {
186        tokio::select! {
187            accept_result = listener.accept() => {
188                let (stream, _address) = accept_result
189                    .map_err(|error| CoreError::SocketError(error.to_string()))?;
190                let client_state = Arc::clone(&state);
191                tokio::spawn(async move {
192                    if let Err(error) = handle_client(stream, client_state).await {
193                        eprintln!("client error: {error}");
194                    }
195                });
196            }
197            _ = sigterm.recv() => {
198                return save_indexes_on_shutdown(&state);
199            }
200        }
201    }
202}
203
204fn spawn_background_reindex(state: Arc<ServerState>) {
205    let interval_secs = state.config.server.reindex_interval_secs;
206    if interval_secs == 0 {
207        return;
208    }
209    tokio::spawn(async move {
210        let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
211        interval.tick().await; // skip first immediate tick
212        loop {
213            interval.tick().await;
214            let reindex_state = Arc::clone(&state);
215            let _ = tokio::task::spawn_blocking(move || {
216                reindex_unindexed_memories(&reindex_state);
217            })
218            .await;
219        }
220    });
221}
222
223/// Reindex unindexed memories from SQLite into the HNSW index.
224///
225/// **Unstable:** exposed for latency benchmarking only
226/// (`crates/engram-core/tests/search_latency_bench.rs`). Not part of the public
227/// API contract; may change or be removed without notice.
228#[doc(hidden)]
229pub fn reindex_unindexed_memories(state: &ServerState) {
230    let database = lock_helpers::lock_db(state);
231    let unindexed = match query_unindexed_ids(&database) {
232        Ok(ids) => ids,
233        Err(_) => return,
234    };
235    if unindexed.is_empty() {
236        return;
237    }
238    let mut indexes = lock_helpers::write_indexes(state);
239    for memory_id in &unindexed {
240        let _ = reindex_single_memory(&database, &mut indexes, memory_id);
241    }
242}
243
244fn query_unindexed_ids(database: &Database) -> Result<Vec<String>, CoreError> {
245    let mut statement = database
246        .connection()
247        .prepare(
248            "SELECT id FROM memories WHERE indexed = 0 \
249             AND embedding_context IS NOT NULL LIMIT 100",
250        )
251        .map_err(|e| CoreError::Storage(engram_storage::StorageError::from(e)))?;
252    let rows = statement
253        .query_map([], |row| row.get::<_, String>(0))
254        .map_err(|e| CoreError::Storage(engram_storage::StorageError::from(e)))?;
255    let ids: Vec<String> = rows.flatten().collect();
256    Ok(ids)
257}
258
259fn reindex_single_memory(
260    database: &Database,
261    indexes: &mut IndexSet,
262    memory_id: &str,
263) -> Result<(), CoreError> {
264    let memory = database.get_memory(memory_id)?;
265    let embedding = crate::persistence::extract_embeddings_from_memory(&memory)?;
266    let id_hash = crate::persistence::hash_string_to_u64(memory_id);
267    let rng_value = crate::persistence::deterministic_rng(id_hash);
268    if !indexes.contains(id_hash) {
269        indexes.insert_atomic(id_hash, memory_id, &embedding, rng_value)?;
270    }
271    database.set_memory_indexed(memory_id, true)?;
272    Ok(())
273}
274
275async fn handle_client(
276    stream: tokio::net::UnixStream,
277    state: Arc<ServerState>,
278) -> Result<(), CoreError> {
279    let (reader, mut writer) = stream.into_split();
280    let mut lines = BufReader::new(reader).lines();
281    while let Some(line) = lines
282        .next_line()
283        .await
284        .map_err(|error| CoreError::SocketError(error.to_string()))?
285    {
286        let response = process_request_line(&line, &state).await;
287        let serialized = serde_json::to_string(&response)
288            .map_err(|error| CoreError::SocketError(error.to_string()))?;
289        writer
290            .write_all(serialized.as_bytes())
291            .await
292            .map_err(|error| CoreError::SocketError(error.to_string()))?;
293        writer
294            .write_all(b"\n")
295            .await
296            .map_err(|error| CoreError::SocketError(error.to_string()))?;
297    }
298    Ok(())
299}
300
301async fn process_request_line(line: &str, state: &Arc<ServerState>) -> JsonResponse {
302    let request: JsonRequest = match serde_json::from_str(line) {
303        Ok(parsed) => parsed,
304        Err(error) => {
305            return JsonResponse::error(String::new(), 4000, format!("invalid json: {error}"));
306        }
307    };
308    let request_id = request.id.clone();
309    match dispatch::route(&request.method, state, request.params).await {
310        Ok(data) => JsonResponse::success(request_id, data),
311        Err(error) => JsonResponse::error(request_id, error_code(&error), error.to_string()),
312    }
313}
314
315fn error_code(error: &CoreError) -> u32 {
316    match error {
317        CoreError::ConfigNotFound => 6001,
318        CoreError::ConfigParseError(_) => 6002,
319        CoreError::InvalidProvider(_) => 6003,
320        CoreError::IndexCorrupted(_) => 6004,
321        CoreError::RebuildFailed(_) => 6005,
322        CoreError::SocketError(_) => 6006,
323        CoreError::DispatchError(_) => 6007,
324        CoreError::ConfigReadOnly => 6008,
325        CoreError::ExportFailed(_) => 6009,
326        CoreError::ImportVersionMismatch(_) => 6010,
327        CoreError::ImportFailed(_) => 6011,
328        CoreError::InitFailed(_) => 6012,
329        CoreError::TrainerFailed(_) => 6013,
330        CoreError::TrainerTimeout => 6014,
331        CoreError::TrainerMalformedOutput(_) => 6015,
332        CoreError::ProjectDirNotFound => 6016,
333        CoreError::LegacyDatabaseDetected { .. } => 6017,
334        CoreError::MigrationSourceNotFound => 6018,
335        CoreError::MigrationFailed(_) => 6019,
336        CoreError::EmbeddingModelMismatch { .. } => 6020,
337        CoreError::IndexHashCollision { .. } => 6021,
338        CoreError::ConfigValidation(_) => 6022,
339        CoreError::Storage(_) => 1000,
340        CoreError::Hnsw(_) => 3000,
341        CoreError::Api(_) => 2000,
342        CoreError::Consolidation(_) => 5000,
343    }
344}
345
346fn save_indexes_on_shutdown(state: &Arc<ServerState>) -> Result<(), CoreError> {
347    let index_directory = resolve_index_directory(&state.database_path);
348    let indexes = lock_helpers::read_indexes(state);
349    crate::persistence::save_to_disk(&index_directory, &indexes)
350}
351
352fn warn_missing_api_keys(config: &Config) {
353    let has_embedding_key = config
354        .embedding
355        .api_key
356        .as_deref()
357        .is_some_and(|k| !k.is_empty());
358    let has_llm_key = config.llm.api_key.as_deref().is_some_and(|k| !k.is_empty());
359
360    if !has_embedding_key
361        && config.embedding.provider != "deterministic"
362        && config.embedding.provider != "ollama"
363    {
364        eprintln!(
365            "warning: {} embedding provider configured without api key (set ENGRAM_VOYAGE_API_KEY)",
366            config.embedding.provider
367        );
368    }
369    if !has_llm_key && config.llm.provider != "local" && config.llm.provider != "ollama" {
370        eprintln!(
371            "warning: {} llm provider configured without api key (set ENGRAM_OPENAI_API_KEY)",
372            config.llm.provider
373        );
374    }
375}
376
377fn expand_tilde(path: &str) -> String {
378    if let Some(rest) = path.strip_prefix("~/")
379        && let Ok(home) = std::env::var("HOME")
380    {
381        return format!("{home}/{rest}");
382    }
383    path.to_string()
384}