Skip to main content

recall_echo/
serve.rs

1//! `recall-echo serve` — the graph daemon.
2//!
3//! One daemon per memory directory owns the embedded graph store and answers
4//! command-level requests over a unix socket, one JSON object per line:
5//!
6//! ```text
7//! → {"op":"search","args":{"query":"rust","limit":5}}
8//! ← {"ok":true,"data":[ ... ]}
9//! ```
10//!
11//! The daemon is *crash-only*: it keeps no state outside the database, so it
12//! can be killed at any instant. Clients ([`crate::serve_client`]) detect the
13//! dead socket, clean it up and start a fresh daemon.
14//!
15//! Unix only — the socket is a plain `UnixListener`, with no transport
16//! abstraction (see RE-29 decisions log).
17
18use std::path::{Path, PathBuf};
19use std::sync::atomic::{AtomicUsize, Ordering};
20use std::sync::{Arc, Mutex};
21use std::time::{Duration, Instant};
22
23use serde::{Deserialize, Serialize};
24use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
25use tokio::net::{UnixListener, UnixStream};
26use tokio::sync::Notify;
27
28use crate::error::RecallError;
29use crate::graph::error::GraphError;
30use crate::graph::types::{
31    EntityType, NewEntity, NewRelationship, PipelineDocuments, QueryOptions, SearchOptions,
32};
33use crate::graph::utility::OutcomeKind;
34use crate::graph::{GraphMemory, IngestContext, Provenance};
35use crate::serve_security::{
36    append_private_file, check_peer_uid, current_uid, unlink_socket, PRIVATE_FILE_MODE,
37};
38
39/// Longest idle-poll interval; keeps a long-lived daemon from spinning.
40const MAX_IDLE_POLL: Duration = Duration::from_secs(30);
41/// Shortest idle-poll interval; keeps short test timeouts responsive.
42const MIN_IDLE_POLL: Duration = Duration::from_millis(100);
43
44/// Largest request line the daemon will read. An archive ingest is the biggest
45/// legitimate request by far and stays far below this; anything larger is a
46/// buggy or hostile client trying to make the daemon buffer without bound.
47const MAX_REQUEST_BYTES: u64 = 8 * 1024 * 1024;
48/// Largest result set a request may ask for. Wire-supplied limits reach the
49/// HNSW KNN operator, where an unbounded value is an unbounded scan.
50const MAX_LIMIT: usize = 1000;
51/// Deepest graph expansion a request may ask for. Expansion is exponential in
52/// the branching factor.
53const MAX_DEPTH: u32 = 8;
54/// How long the daemon waits for its own store to close before giving up and
55/// unlinking the socket anyway.
56const STORE_RELEASE_TIMEOUT: Duration = Duration::from_secs(10);
57/// Polling interval while waiting for connection tasks to release the store.
58const STORE_RELEASE_POLL: Duration = Duration::from_millis(10);
59
60// ── Protocol ─────────────────────────────────────────────────────────────
61
62/// A client request. Wire form is `{"op": "...", "args": {...}}`.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(tag = "op", content = "args", rename_all = "snake_case")]
65pub enum Request {
66    /// Version handshake — returns [`DaemonInfo`].
67    Hello,
68    /// Graph counts.
69    Status,
70    /// Semantic entity search.
71    Search(SearchArgs),
72    /// Semantic episode search.
73    SearchEpisodes(SearchEpisodesArgs),
74    /// Hybrid query: semantic + graph expansion + optional episodes.
75    Query(QueryArgs),
76    /// Traverse relationships from a named entity.
77    Traverse(TraverseArgs),
78    /// Create an entity.
79    AddEntity(AddEntityArgs),
80    /// Create a relationship between two named entities.
81    Relate(RelateArgs),
82    /// Ingest a conversation archive (episodes only, no LLM extraction).
83    IngestArchive(IngestArchiveArgs),
84    /// Sync the pipeline documents into the graph (no LLM extraction).
85    SyncPipeline(SyncPipelineArgs),
86    /// Apply an outcome to the entities a session touched.
87    Feedback(FeedbackArgs),
88    /// Ask the daemon to exit.
89    Shutdown,
90}
91
92impl Request {
93    /// Short name of the operation, for logs.
94    #[must_use]
95    pub fn op_name(&self) -> &'static str {
96        match self {
97            Request::Hello => "hello",
98            Request::Status => "status",
99            Request::Search(_) => "search",
100            Request::SearchEpisodes(_) => "search_episodes",
101            Request::Query(_) => "query",
102            Request::Traverse(_) => "traverse",
103            Request::AddEntity(_) => "add_entity",
104            Request::Relate(_) => "relate",
105            Request::IngestArchive(_) => "ingest_archive",
106            Request::SyncPipeline(_) => "sync_pipeline",
107            Request::Feedback(_) => "feedback",
108            Request::Shutdown => "shutdown",
109        }
110    }
111
112    /// Whether repeating this request against a fresh daemon is safe.
113    ///
114    /// A connection that drops mid-request cannot tell us whether the daemon
115    /// applied it before dying, so only read-only or idempotent operations may
116    /// be retried. Repeating an archive ingest would duplicate its episodes —
117    /// a silently corrupted memory is worse than a reported failure.
118    #[must_use]
119    pub fn is_retryable(&self) -> bool {
120        match self {
121            Request::Hello
122            | Request::Status
123            | Request::Search(_)
124            | Request::SearchEpisodes(_)
125            | Request::Query(_)
126            | Request::Traverse(_)
127            // Pipeline sync diffs documents against the graph.
128            | Request::SyncPipeline(_)
129            // Outcome records replace per (entity, session) — reruns correct.
130            | Request::Feedback(_)
131            | Request::Shutdown => true,
132            Request::AddEntity(_) | Request::Relate(_) | Request::IngestArchive(_) => false,
133        }
134    }
135}
136
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct SearchArgs {
139    pub query: String,
140    pub limit: usize,
141    #[serde(default)]
142    pub entity_type: Option<String>,
143    #[serde(default)]
144    pub keyword: Option<String>,
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct SearchEpisodesArgs {
149    pub query: String,
150    pub limit: usize,
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct QueryArgs {
155    pub query: String,
156    pub limit: usize,
157    #[serde(default)]
158    pub entity_type: Option<String>,
159    #[serde(default)]
160    pub keyword: Option<String>,
161    pub depth: u32,
162    pub episodes: bool,
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct TraverseArgs {
167    pub entity: String,
168    pub depth: u32,
169    #[serde(default)]
170    pub type_filter: Option<String>,
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174pub struct AddEntityArgs {
175    pub name: String,
176    pub entity_type: String,
177    pub abstract_text: String,
178    #[serde(default)]
179    pub overview: Option<String>,
180    #[serde(default)]
181    pub source: Option<String>,
182}
183
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185pub struct RelateArgs {
186    pub from: String,
187    pub rel_type: String,
188    pub to: String,
189    #[serde(default)]
190    pub description: Option<String>,
191    #[serde(default)]
192    pub source: Option<String>,
193}
194
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196pub struct IngestArchiveArgs {
197    pub content: String,
198    pub session_id: String,
199    #[serde(default)]
200    pub log_number: Option<u32>,
201    /// Force one provenance class on every episode of this run. Absent — the
202    /// shape older clients send — means infer per chunk from turn roles.
203    #[serde(default)]
204    pub provenance: Option<Provenance>,
205}
206
207/// Pipeline sync needs no LLM provider, so it runs against the daemon like any
208/// other graph operation instead of taking the store exclusively.
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
210pub struct SyncPipelineArgs {
211    pub docs: PipelineDocuments,
212}
213
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215pub struct FeedbackArgs {
216    pub session_id: String,
217    pub outcome: OutcomeKind,
218}
219
220/// A daemon response. Wire form is `{"ok": true, "data": ...}` or
221/// `{"ok": false, "error": {"code": "...", "message": "..."}}`.
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223pub struct Response {
224    pub ok: bool,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub data: Option<serde_json::Value>,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub error: Option<ResponseError>,
229}
230
231/// A named failure. `code` is stable and machine-readable; `message` is the
232/// human-readable text (already prefixed by the error kind, e.g. `store locked`).
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234pub struct ResponseError {
235    pub code: String,
236    pub message: String,
237}
238
239impl Response {
240    /// A successful response carrying `data`.
241    #[must_use]
242    pub fn success(data: serde_json::Value) -> Self {
243        Self {
244            ok: true,
245            data: Some(data),
246            error: None,
247        }
248    }
249
250    /// A failed response with a stable `code` and human-readable `message`.
251    #[must_use]
252    pub fn failure(code: impl Into<String>, message: impl Into<String>) -> Self {
253        Self {
254            ok: false,
255            data: None,
256            error: Some(ResponseError {
257                code: code.into(),
258                message: message.into(),
259            }),
260        }
261    }
262
263    /// Convert a graph error into a coded failure response.
264    #[must_use]
265    pub fn from_graph_error(err: &GraphError) -> Self {
266        Self::failure(error_code(err), err.to_string())
267    }
268
269    /// Unwrap into the client-side result: data on success, a named
270    /// [`RecallError::Remote`] on failure.
271    pub fn into_result(self) -> Result<serde_json::Value, RecallError> {
272        if self.ok {
273            return Ok(self.data.unwrap_or(serde_json::Value::Null));
274        }
275        let error = self.error.unwrap_or(ResponseError {
276            code: "unknown".into(),
277            message: "daemon reported failure without a message".into(),
278        });
279        Err(RecallError::Remote {
280            code: error.code,
281            message: error.message,
282        })
283    }
284}
285
286/// Stable machine-readable code for a graph error.
287fn error_code(err: &GraphError) -> &'static str {
288    match err {
289        GraphError::Db(_) => "db",
290        GraphError::Locked(_) => "locked",
291        GraphError::Embed(_) => "embedding",
292        GraphError::NotFound(_) => "not_found",
293        GraphError::Extraction(_) => "extraction",
294        GraphError::Dedup(_) => "dedup",
295        GraphError::Llm(_) => "llm",
296        GraphError::Parse(_) => "parse",
297        GraphError::Io(_) => "io",
298        GraphError::Json(_) => "json",
299        GraphError::ImmutableMerge(_) => "immutable_merge",
300    }
301}
302
303/// Identity of a running daemon, returned by [`Request::Hello`].
304#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
305pub struct DaemonInfo {
306    pub version: String,
307    pub pid: u32,
308    pub memory_dir: String,
309    pub socket_path: String,
310    pub uptime_secs: u64,
311}
312
313// ── Dispatch ─────────────────────────────────────────────────────────────
314
315/// Execute a graph operation against an open store.
316///
317/// Control operations ([`Request::Hello`], [`Request::Shutdown`]) are owned by
318/// the connection loop and reported as `unsupported` here.
319pub async fn dispatch_graph(graph: &GraphMemory, request: &Request) -> Response {
320    match execute_graph(graph, request).await {
321        Ok(Some(data)) => Response::success(data),
322        Ok(None) => Response::failure(
323            "unsupported",
324            format!(
325                "`{}` is a control operation, not a graph operation",
326                request.op_name()
327            ),
328        ),
329        Err(err) => Response::from_graph_error(&err),
330    }
331}
332
333/// Clamp a wire-supplied result limit into a range the store can serve.
334fn clamp_limit(limit: usize) -> usize {
335    limit.clamp(1, MAX_LIMIT)
336}
337
338/// Clamp a wire-supplied expansion depth.
339fn clamp_depth(depth: u32) -> u32 {
340    depth.clamp(1, MAX_DEPTH)
341}
342
343/// `Ok(None)` means "not a graph operation".
344async fn execute_graph(
345    graph: &GraphMemory,
346    request: &Request,
347) -> Result<Option<serde_json::Value>, GraphError> {
348    let data = match request {
349        Request::Hello | Request::Shutdown => return Ok(None),
350        Request::Status => serde_json::to_value(graph.stats().await?)?,
351        Request::Search(args) => {
352            let options = SearchOptions {
353                limit: clamp_limit(args.limit),
354                entity_type: args.entity_type.clone(),
355                keyword: args.keyword.clone(),
356            };
357            serde_json::to_value(graph.search_with_options(&args.query, &options).await?)?
358        }
359        Request::SearchEpisodes(args) => {
360            let mut episodes = graph
361                .search_episodes(&args.query, clamp_limit(args.limit))
362                .await?;
363            for result in &mut episodes {
364                result.episode.embedding = None;
365            }
366            serde_json::to_value(episodes)?
367        }
368        Request::Query(args) => {
369            let options = QueryOptions {
370                limit: clamp_limit(args.limit),
371                entity_type: args.entity_type.clone(),
372                keyword: args.keyword.clone(),
373                graph_depth: clamp_depth(args.depth),
374                include_episodes: args.episodes,
375            };
376            let mut result = graph.query(&args.query, &options).await?;
377            for episode in &mut result.episodes {
378                episode.episode.embedding = None;
379            }
380            serde_json::to_value(result)?
381        }
382        Request::Traverse(args) => serde_json::to_value(
383            graph
384                .traverse_filtered(
385                    &args.entity,
386                    clamp_depth(args.depth),
387                    args.type_filter.as_deref(),
388                )
389                .await?,
390        )?,
391        Request::AddEntity(args) => {
392            let entity_type: EntityType = args
393                .entity_type
394                .parse()
395                .map_err(|e: String| GraphError::Parse(e))?;
396            let mut entity = graph
397                .add_entity(NewEntity {
398                    name: args.name.clone(),
399                    entity_type,
400                    abstract_text: args.abstract_text.clone(),
401                    overview: args.overview.clone(),
402                    content: None,
403                    attributes: None,
404                    source: args.source.clone(),
405                })
406                .await?;
407            // 384 floats of JSON text no client has ever read.
408            entity.embedding = None;
409            serde_json::to_value(entity)?
410        }
411        Request::Relate(args) => {
412            let relationship = graph
413                .add_relationship(NewRelationship {
414                    from_entity: args.from.clone(),
415                    to_entity: args.to.clone(),
416                    rel_type: args.rel_type.clone(),
417                    description: args.description.clone(),
418                    confidence: None,
419                    source: args.source.clone(),
420                })
421                .await?;
422            serde_json::to_value(relationship)?
423        }
424        Request::IngestArchive(args) => {
425            let context = IngestContext::new(args.session_id.clone(), args.log_number)
426                .with_override(args.provenance);
427            let report = graph.ingest_archive(&args.content, &context, None).await?;
428            serde_json::to_value(report)?
429        }
430        Request::SyncPipeline(args) => {
431            serde_json::to_value(graph.sync_pipeline(&args.docs).await?)?
432        }
433        Request::Feedback(args) => serde_json::to_value(
434            graph
435                .record_session_outcome(&args.session_id, args.outcome)
436                .await?,
437        )?,
438    };
439    Ok(Some(data))
440}
441
442// ── Idle tracking ────────────────────────────────────────────────────────
443
444/// Tracks daemon activity so an unused daemon shuts itself down.
445///
446/// A daemon is idle when no connection is open *and* the last completed
447/// request is older than the configured timeout. `None` disables idle
448/// shutdown entirely (`--foreground`, or `idle_timeout_secs = 0`).
449#[derive(Debug)]
450pub struct IdleTracker {
451    timeout: Option<Duration>,
452    active: AtomicUsize,
453    last_activity: Mutex<Instant>,
454}
455
456impl IdleTracker {
457    #[must_use]
458    pub fn new(timeout: Option<Duration>) -> Self {
459        Self::new_at(timeout, Instant::now())
460    }
461
462    /// Construct with an explicit start instant (used by tests).
463    #[must_use]
464    pub fn new_at(timeout: Option<Duration>, start: Instant) -> Self {
465        Self {
466            timeout,
467            active: AtomicUsize::new(0),
468            last_activity: Mutex::new(start),
469        }
470    }
471
472    /// Register a connection as open.
473    pub fn begin(&self) {
474        self.active.fetch_add(1, Ordering::SeqCst);
475        self.touch_at(Instant::now());
476    }
477
478    /// Register a connection as closed.
479    pub fn end(&self) {
480        let previous = self.active.fetch_sub(1, Ordering::SeqCst);
481        debug_assert!(previous > 0, "IdleTracker::end without begin");
482        self.touch_at(Instant::now());
483    }
484
485    /// Record activity at `now`.
486    pub fn touch_at(&self, now: Instant) {
487        let mut last = self
488            .last_activity
489            .lock()
490            .unwrap_or_else(|poisoned| poisoned.into_inner());
491        *last = now;
492    }
493
494    /// True when the daemon has been unused for longer than the timeout.
495    #[must_use]
496    pub fn is_idle_at(&self, now: Instant) -> bool {
497        let Some(timeout) = self.timeout else {
498            return false;
499        };
500        if self.active.load(Ordering::SeqCst) > 0 {
501            return false;
502        }
503        let last = *self
504            .last_activity
505            .lock()
506            .unwrap_or_else(|poisoned| poisoned.into_inner());
507        now.saturating_duration_since(last) >= timeout
508    }
509
510    /// How often the accept loop should re-check idleness.
511    #[must_use]
512    pub fn poll_interval(&self) -> Duration {
513        match self.timeout {
514            None => MAX_IDLE_POLL,
515            Some(timeout) => (timeout / 10).clamp(MIN_IDLE_POLL, MAX_IDLE_POLL),
516        }
517    }
518}
519
520/// RAII connection counter for [`IdleTracker`].
521struct ActivityGuard(Arc<DaemonContext>);
522
523impl ActivityGuard {
524    fn new(context: Arc<DaemonContext>) -> Self {
525        context.idle.begin();
526        Self(context)
527    }
528}
529
530impl Drop for ActivityGuard {
531    fn drop(&mut self) {
532        self.0.idle.end();
533    }
534}
535
536// ── Logging ──────────────────────────────────────────────────────────────
537
538/// Append-only daemon log at `<memory_dir>/graph/daemon.log`.
539///
540/// Never fails a request: if the file cannot be opened, lines go to stderr.
541pub struct DaemonLog {
542    file: Mutex<Option<std::fs::File>>,
543    echo_stderr: bool,
544}
545
546impl DaemonLog {
547    #[must_use]
548    pub fn open(path: &Path, echo_stderr: bool) -> Self {
549        let file = append_private_file().open(path).ok();
550        Self {
551            file: Mutex::new(file),
552            echo_stderr,
553        }
554    }
555
556    pub fn log(&self, message: &str) {
557        use std::io::Write as _;
558        let line = format!(
559            "[{}] pid={} {message}\n",
560            chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"),
561            std::process::id(),
562        );
563        let mut guard = self
564            .file
565            .lock()
566            .unwrap_or_else(|poisoned| poisoned.into_inner());
567        match guard.as_mut() {
568            Some(file) => {
569                let _ = file.write_all(line.as_bytes());
570                let _ = file.flush();
571                if self.echo_stderr {
572                    eprint!("{line}");
573                }
574            }
575            None => eprint!("{line}"),
576        }
577    }
578}
579
580// ── Daemon ───────────────────────────────────────────────────────────────
581
582/// Everything `serve` needs to run.
583#[derive(Debug, Clone)]
584pub struct ServeOptions {
585    /// The memory directory whose `graph/` store this daemon owns.
586    pub memory_dir: PathBuf,
587    /// Unix socket to listen on.
588    pub socket_path: PathBuf,
589    /// Idle shutdown timeout; `None` never shuts down.
590    pub idle_timeout: Option<Duration>,
591    /// Mirror the daemon log to stderr (foreground / systemd mode).
592    pub log_to_stderr: bool,
593}
594
595impl ServeOptions {
596    /// Build options from `[serve]` in the memory directory's config.
597    ///
598    /// `foreground` (systemd) disables idle shutdown and mirrors the log to
599    /// stderr, leaving daemon lifetime to the supervisor.
600    pub fn from_config(memory_dir: &Path, foreground: bool) -> Result<Self, RecallError> {
601        let config = crate::config::load_from_dir(memory_dir);
602        let idle_timeout = match (foreground, config.serve.idle_timeout_secs) {
603            (true, _) | (_, 0) => None,
604            (false, secs) => Some(Duration::from_secs(secs)),
605        };
606        Ok(Self {
607            memory_dir: memory_dir.to_path_buf(),
608            socket_path: crate::serve_client::socket_path(memory_dir)?,
609            idle_timeout,
610            log_to_stderr: foreground,
611        })
612    }
613}
614
615/// Shared, immutable-ish daemon state.
616struct DaemonContext {
617    started: Instant,
618    memory_dir: PathBuf,
619    socket_path: PathBuf,
620    /// Only this uid may use the socket.
621    owner_uid: u32,
622    idle: IdleTracker,
623    shutdown: Notify,
624}
625
626impl DaemonContext {
627    fn info(&self) -> DaemonInfo {
628        DaemonInfo {
629            version: env!("CARGO_PKG_VERSION").to_string(),
630            pid: std::process::id(),
631            memory_dir: self.memory_dir.display().to_string(),
632            socket_path: self.socket_path.display().to_string(),
633            uptime_secs: self.started.elapsed().as_secs(),
634        }
635    }
636}
637
638/// Path of the pidfile that accompanies a socket.
639#[must_use]
640pub fn pidfile_path(socket_path: &Path) -> PathBuf {
641    let mut path = socket_path.as_os_str().to_os_string();
642    path.push(".pid");
643    PathBuf::from(path)
644}
645
646/// Run the daemon until it is asked to stop or goes idle.
647pub async fn run(options: ServeOptions) -> Result<(), RecallError> {
648    let graph_dir = options.memory_dir.join("graph");
649    std::fs::create_dir_all(&graph_dir)?;
650
651    let log = Arc::new(DaemonLog::open(
652        &graph_dir.join("daemon.log"),
653        options.log_to_stderr,
654    ));
655    log.log(&format!(
656        "starting v{} for {} on {}",
657        env!("CARGO_PKG_VERSION"),
658        options.memory_dir.display(),
659        options.socket_path.display()
660    ));
661
662    if crate::serve_client::graph_mode(&options.memory_dir) == "server" {
663        log.log("warning: [graph] mode = \"server\" — clients bypass the daemon");
664    }
665
666    let owner_uid = current_uid()?;
667
668    // Own the store before advertising the socket: clients that connect only
669    // after a successful bind never see a half-initialized daemon.
670    let graph = open_store(&graph_dir, &options.socket_path, &log).await?;
671
672    let listener = match bind_socket(&options.socket_path) {
673        Ok(listener) => listener,
674        Err(err) => {
675            log.log(&format!("failed to bind socket: {err}"));
676            return Err(err);
677        }
678    };
679    write_pidfile(&options.socket_path)?;
680
681    let context = Arc::new(DaemonContext {
682        started: Instant::now(),
683        memory_dir: options.memory_dir.clone(),
684        socket_path: options.socket_path.clone(),
685        owner_uid,
686        idle: IdleTracker::new(options.idle_timeout),
687        shutdown: Notify::new(),
688    });
689    warm_embedder(Arc::clone(&graph), Arc::clone(&log));
690    log.log("ready");
691
692    accept_loop(
693        listener,
694        Arc::clone(&graph),
695        Arc::clone(&context),
696        Arc::clone(&log),
697    )
698    .await;
699
700    // Close the store *before* the socket disappears: a client waiting for the
701    // socket to go treats that as "the store is free", and would otherwise
702    // race the SurrealKV file lock we have not released yet.
703    release_store(graph, &log).await;
704    if let Err(err) = unlink_socket(&options.socket_path) {
705        log.log(&format!("socket cleanup: {err}"));
706    }
707    let _ = std::fs::remove_file(pidfile_path(&options.socket_path));
708    log.log("stopped");
709    Ok(())
710}
711
712/// Open the store, waiting out an admin operation that currently owns it.
713async fn open_store(
714    graph_dir: &Path,
715    socket_path: &Path,
716    log: &DaemonLog,
717) -> Result<Arc<GraphMemory>, RecallError> {
718    let deadline = Instant::now() + crate::serve_client::ADMIN_WAIT_TIMEOUT;
719    loop {
720        crate::serve_client::wait_for_admin_lock(socket_path, deadline).await?;
721        match GraphMemory::open_embedded(graph_dir).await {
722            Ok(graph) => return Ok(Arc::new(graph)),
723            Err(GraphError::Locked(message))
724                if Instant::now() < deadline
725                    && crate::serve_client::admin_lock_is_held(socket_path) =>
726            {
727                log.log(&format!("waiting for an admin operation: {message}"));
728            }
729            Err(err) => {
730                log.log(&format!("failed to open graph store: {err}"));
731                return Err(err.into());
732            }
733        }
734    }
735}
736
737/// Drop the store once every in-flight connection has let go of it.
738async fn release_store(graph: Arc<GraphMemory>, log: &DaemonLog) {
739    let deadline = Instant::now() + STORE_RELEASE_TIMEOUT;
740    let mut graph = graph;
741    loop {
742        match Arc::try_unwrap(graph) {
743            Ok(store) => {
744                drop(store);
745                return;
746            }
747            Err(shared) => {
748                if Instant::now() >= deadline {
749                    log.log("gave up waiting for in-flight requests to release the store");
750                    return;
751                }
752                graph = shared;
753                tokio::time::sleep(STORE_RELEASE_POLL).await;
754            }
755        }
756    }
757}
758
759/// Load the ONNX embedding model in the background, so the first request that
760/// needs an embedding does not pay for it inline.
761///
762/// Skipped while the model cache is empty: warming a cold cache downloads the
763/// model, which must stay tied to a request that actually needs it rather than
764/// happening on every daemon start.
765fn warm_embedder(graph: Arc<GraphMemory>, log: Arc<DaemonLog>) {
766    let models_dir = graph.path().join("models");
767    if !has_cached_model(&models_dir) {
768        return;
769    }
770    tokio::task::spawn_blocking(move || {
771        let started = Instant::now();
772        match graph.embedder() {
773            Ok(_) => log.log(&format!(
774                "embedder warm in {}ms",
775                started.elapsed().as_millis()
776            )),
777            Err(err) => log.log(&format!("embedder warm-up failed: {err}")),
778        }
779    });
780}
781
782fn has_cached_model(models_dir: &Path) -> bool {
783    std::fs::read_dir(models_dir).is_ok_and(|mut entries| entries.next().is_some())
784}
785
786async fn accept_loop(
787    listener: UnixListener,
788    graph: Arc<GraphMemory>,
789    context: Arc<DaemonContext>,
790    log: Arc<DaemonLog>,
791) {
792    loop {
793        let poll = context.idle.poll_interval();
794        tokio::select! {
795            accepted = listener.accept() => match accepted {
796                Ok((stream, _)) => {
797                    let graph = Arc::clone(&graph);
798                    let context = Arc::clone(&context);
799                    let log = Arc::clone(&log);
800                    tokio::spawn(async move {
801                        handle_connection(stream, graph, context, log).await;
802                    });
803                }
804                Err(err) => log.log(&format!("accept error: {err}")),
805            },
806            () = context.shutdown.notified() => {
807                log.log("shutdown requested");
808                break;
809            }
810            () = tokio::time::sleep(poll) => {
811                if context.idle.is_idle_at(Instant::now()) {
812                    log.log("idle timeout — exiting");
813                    break;
814                }
815            }
816        }
817    }
818}
819
820async fn handle_connection(
821    stream: UnixStream,
822    graph: Arc<GraphMemory>,
823    context: Arc<DaemonContext>,
824    log: Arc<DaemonLog>,
825) {
826    let _activity = ActivityGuard::new(Arc::clone(&context));
827    if let Err(err) = authorize_peer(&stream, context.owner_uid) {
828        log.log(&format!("rejected connection: {err}"));
829        return;
830    }
831
832    let (reader, mut writer) = stream.into_split();
833    let mut lines = BufReader::new(reader.take(MAX_REQUEST_BYTES)).lines();
834
835    loop {
836        let line = match lines.next_line().await {
837            Ok(Some(line)) => line,
838            Ok(None) => {
839                // The reader stopped at the byte cap instead of at a newline:
840                // the client is sending a request larger than we will read.
841                if request_cap_reached(&mut lines) {
842                    let response = Response::failure(
843                        "bad_request",
844                        format!("request exceeds the {MAX_REQUEST_BYTES}-byte limit"),
845                    );
846                    log.log("rejected an oversized request");
847                    let _ = write_response(&mut writer, &response).await;
848                }
849                break;
850            }
851            Err(err) => {
852                log.log(&format!("read error: {err}"));
853                break;
854            }
855        };
856        recharge_request_cap(&mut lines);
857        if line.trim().is_empty() {
858            continue;
859        }
860
861        let (response, stop) = match serde_json::from_str::<Request>(&line) {
862            Ok(Request::Hello) => (
863                Response::success(
864                    serde_json::to_value(context.info()).unwrap_or(serde_json::Value::Null),
865                ),
866                false,
867            ),
868            Ok(Request::Shutdown) => (
869                Response::success(serde_json::json!({ "stopping": true })),
870                true,
871            ),
872            Ok(request) => {
873                let started = Instant::now();
874                let response = dispatch_graph(&graph, &request).await;
875                log.log(&format!(
876                    "{} {} in {}ms",
877                    request.op_name(),
878                    if response.ok { "ok" } else { "failed" },
879                    started.elapsed().as_millis()
880                ));
881                (response, false)
882            }
883            Err(err) => (
884                Response::failure("bad_request", format!("malformed request: {err}")),
885                false,
886            ),
887        };
888
889        if let Err(err) = write_response(&mut writer, &response).await {
890            log.log(&format!("write error: {err}"));
891            break;
892        }
893        context.idle.touch_at(Instant::now());
894
895        if stop {
896            // `notify_one` stores a permit when the accept loop happens to be
897            // between `select!` iterations; `notify_waiters` would be lost
898            // there and the daemon would outlive the shutdown request.
899            context.shutdown.notify_one();
900            break;
901        }
902    }
903}
904
905/// A connection's request reader, capped at [`MAX_REQUEST_BYTES`] per request.
906type RequestLines = tokio::io::Lines<BufReader<tokio::io::Take<tokio::net::unix::OwnedReadHalf>>>;
907
908/// True when the reader stopped because the request hit the byte cap.
909fn request_cap_reached(lines: &mut RequestLines) -> bool {
910    lines.get_mut().get_mut().limit() == 0
911}
912
913/// Give the next request on this connection its own full byte budget.
914fn recharge_request_cap(lines: &mut RequestLines) {
915    lines.get_mut().get_mut().set_limit(MAX_REQUEST_BYTES);
916}
917
918/// The socket has no authentication of its own: anyone who can open it can
919/// read every ingest payload and forge every answer. Only our own uid may.
920fn authorize_peer(stream: &UnixStream, owner_uid: u32) -> Result<(), RecallError> {
921    let peer = stream.peer_cred().map_err(|err| {
922        RecallError::Daemon(format!("cannot read socket peer credentials: {err}"))
923    })?;
924    check_peer_uid(peer.uid(), owner_uid)
925}
926
927async fn write_response(
928    writer: &mut tokio::net::unix::OwnedWriteHalf,
929    response: &Response,
930) -> Result<(), RecallError> {
931    let mut line = serde_json::to_vec(response)?;
932    line.push(b'\n');
933    writer.write_all(&line).await?;
934    writer.flush().await?;
935    Ok(())
936}
937
938/// Path the socket is bound to before it is published under its real name.
939fn staging_path(socket_path: &Path) -> PathBuf {
940    let mut path = socket_path.as_os_str().to_os_string();
941    path.push(".new");
942    PathBuf::from(path)
943}
944
945/// Bind the listening socket, clearing a stale socket left by a dead daemon.
946///
947/// The socket is bound under a temporary name in the same directory, made
948/// owner-only, and only then renamed into place: `bind` applies the process
949/// umask, so publishing first would leave a world-reachable socket for as long
950/// as it takes to chmod it.
951fn bind_socket(socket_path: &Path) -> Result<UnixListener, RecallError> {
952    use std::os::unix::fs::PermissionsExt;
953
954    if let Some(parent) = socket_path.parent() {
955        crate::serve_client::ensure_socket_dir(parent)?;
956    }
957    if std::os::unix::net::UnixStream::connect(socket_path).is_ok() {
958        return Err(RecallError::Daemon(format!(
959            "another daemon is already listening on {}",
960            socket_path.display()
961        )));
962    }
963
964    let staged = staging_path(socket_path);
965    unlink_socket(&staged)?;
966    let listener = UnixListener::bind(&staged).map_err(|err| {
967        RecallError::Daemon(format!("cannot listen on {}: {err}", staged.display()))
968    })?;
969    std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(PRIVATE_FILE_MODE)).map_err(
970        |err| {
971            RecallError::Daemon(format!(
972                "cannot restrict the daemon socket {}: {err}",
973                staged.display()
974            ))
975        },
976    )?;
977
978    // Whatever sits at the published path is a socket a dead daemon left.
979    unlink_socket(socket_path)?;
980    std::fs::rename(&staged, socket_path).map_err(|err| {
981        let _ = std::fs::remove_file(&staged);
982        RecallError::Daemon(format!(
983            "cannot publish the daemon socket at {}: {err}",
984            socket_path.display()
985        ))
986    })?;
987    Ok(listener)
988}
989
990fn write_pidfile(socket_path: &Path) -> Result<(), RecallError> {
991    use std::io::Write as _;
992
993    let contents = serde_json::json!({
994        "pid": std::process::id(),
995        "version": env!("CARGO_PKG_VERSION"),
996        "socket_path": socket_path.display().to_string(),
997    });
998    let path = pidfile_path(socket_path);
999    let _ = std::fs::remove_file(&path);
1000    let mut file = crate::serve_security::create_new_private_file().open(&path)?;
1001    writeln!(file, "{contents}")?;
1002    Ok(())
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008
1009    const fn assert_shareable<T: Send + Sync>() {}
1010
1011    #[test]
1012    fn graph_memory_is_shareable_across_tasks() {
1013        // The daemon hands one Arc<GraphMemory> to every connection task.
1014        assert_shareable::<GraphMemory>();
1015    }
1016
1017    #[test]
1018    fn request_wire_format_is_op_and_args() {
1019        let request = Request::Search(SearchArgs {
1020            query: "rust".into(),
1021            limit: 5,
1022            entity_type: None,
1023            keyword: None,
1024        });
1025        let json = serde_json::to_value(&request).unwrap();
1026        assert_eq!(json["op"], "search");
1027        assert_eq!(json["args"]["query"], "rust");
1028        assert_eq!(json["args"]["limit"], 5);
1029    }
1030
1031    #[test]
1032    fn control_requests_serialize_without_args() {
1033        assert_eq!(
1034            serde_json::to_string(&Request::Hello).unwrap(),
1035            r#"{"op":"hello"}"#
1036        );
1037        assert_eq!(
1038            serde_json::to_string(&Request::Shutdown).unwrap(),
1039            r#"{"op":"shutdown"}"#
1040        );
1041    }
1042
1043    #[test]
1044    fn request_round_trips_every_variant() {
1045        let requests = vec![
1046            Request::Hello,
1047            Request::Status,
1048            Request::Search(SearchArgs {
1049                query: "q".into(),
1050                limit: 3,
1051                entity_type: Some("tool".into()),
1052                keyword: Some("k".into()),
1053            }),
1054            Request::SearchEpisodes(SearchEpisodesArgs {
1055                query: "q".into(),
1056                limit: 2,
1057            }),
1058            Request::Query(QueryArgs {
1059                query: "q".into(),
1060                limit: 10,
1061                entity_type: None,
1062                keyword: None,
1063                depth: 2,
1064                episodes: true,
1065            }),
1066            Request::Traverse(TraverseArgs {
1067                entity: "Rust".into(),
1068                depth: 1,
1069                type_filter: None,
1070            }),
1071            Request::AddEntity(AddEntityArgs {
1072                name: "Rust".into(),
1073                entity_type: "tool".into(),
1074                abstract_text: "language".into(),
1075                overview: None,
1076                source: Some("test".into()),
1077            }),
1078            Request::Relate(RelateArgs {
1079                from: "D".into(),
1080                rel_type: "USES".into(),
1081                to: "Rust".into(),
1082                description: None,
1083                source: None,
1084            }),
1085            Request::IngestArchive(IngestArchiveArgs {
1086                content: "# log".into(),
1087                session_id: "s1".into(),
1088                log_number: Some(7),
1089                provenance: Some(Provenance::External),
1090            }),
1091            Request::SyncPipeline(SyncPipelineArgs {
1092                docs: PipelineDocuments {
1093                    learning: "# learning".into(),
1094                    ..PipelineDocuments::default()
1095                },
1096            }),
1097            Request::Feedback(FeedbackArgs {
1098                session_id: "s1".into(),
1099                outcome: OutcomeKind::Success,
1100            }),
1101            Request::Shutdown,
1102        ];
1103
1104        for request in requests {
1105            let line = serde_json::to_string(&request).unwrap();
1106            let parsed: Request = serde_json::from_str(&line).unwrap();
1107            assert_eq!(parsed, request, "round trip failed for {line}");
1108        }
1109    }
1110
1111    #[test]
1112    fn ingest_requests_without_provenance_still_parse() {
1113        // The wire shape older clients send: absent means "infer from turn
1114        // roles", so a pre-provenance client keeps working unchanged.
1115        let parsed: Request = serde_json::from_str(
1116            r##"{"op":"ingest_archive","args":{"content":"# log","session_id":"s1"}}"##,
1117        )
1118        .unwrap();
1119        assert_eq!(
1120            parsed,
1121            Request::IngestArchive(IngestArchiveArgs {
1122                content: "# log".into(),
1123                session_id: "s1".into(),
1124                log_number: None,
1125                provenance: None,
1126            })
1127        );
1128    }
1129
1130    #[test]
1131    fn feedback_is_a_hot_op_with_a_snake_case_outcome() {
1132        let request = Request::Feedback(FeedbackArgs {
1133            session_id: "conversation-042".into(),
1134            outcome: OutcomeKind::Failed,
1135        });
1136        let json = serde_json::to_value(&request).unwrap();
1137        assert_eq!(json["op"], "feedback");
1138        assert_eq!(json["args"]["session_id"], "conversation-042");
1139        assert_eq!(json["args"]["outcome"], "failed");
1140        assert_eq!(request.op_name(), "feedback");
1141    }
1142
1143    #[test]
1144    fn optional_args_may_be_omitted_on_the_wire() {
1145        let parsed: Request =
1146            serde_json::from_str(r#"{"op":"search","args":{"query":"q","limit":1}}"#).unwrap();
1147        assert_eq!(
1148            parsed,
1149            Request::Search(SearchArgs {
1150                query: "q".into(),
1151                limit: 1,
1152                entity_type: None,
1153                keyword: None,
1154            })
1155        );
1156    }
1157
1158    #[test]
1159    fn success_response_carries_data_only() {
1160        let response = Response::success(serde_json::json!({"n": 1}));
1161        let json = serde_json::to_value(&response).unwrap();
1162        assert_eq!(json["ok"], true);
1163        assert_eq!(json["data"]["n"], 1);
1164        assert!(json.get("error").is_none());
1165        assert_eq!(response.into_result().unwrap(), serde_json::json!({"n": 1}));
1166    }
1167
1168    #[test]
1169    fn failure_response_maps_to_named_remote_error() {
1170        let response = Response::from_graph_error(&GraphError::Locked("store busy".into()));
1171        assert_eq!(response.error.as_ref().unwrap().code, "locked");
1172        let err = response.into_result().unwrap_err();
1173        assert!(err.to_string().contains("store locked"));
1174        assert!(matches!(err, RecallError::Remote { .. }));
1175    }
1176
1177    #[test]
1178    fn failure_response_round_trips() {
1179        let response = Response::failure("bad_request", "malformed");
1180        let line = serde_json::to_string(&response).unwrap();
1181        let parsed: Response = serde_json::from_str(&line).unwrap();
1182        assert_eq!(parsed, response);
1183        assert!(!parsed.ok);
1184    }
1185
1186    #[test]
1187    fn error_codes_are_distinct_per_kind() {
1188        assert_eq!(error_code(&GraphError::Locked(String::new())), "locked");
1189        assert_eq!(
1190            error_code(&GraphError::NotFound(String::new())),
1191            "not_found"
1192        );
1193        assert_eq!(error_code(&GraphError::Embed(String::new())), "embedding");
1194    }
1195
1196    #[test]
1197    fn idle_timer_fires_after_timeout() {
1198        let start = Instant::now();
1199        let tracker = IdleTracker::new_at(Some(Duration::from_secs(60)), start);
1200
1201        assert!(!tracker.is_idle_at(start + Duration::from_secs(59)));
1202        assert!(tracker.is_idle_at(start + Duration::from_secs(60)));
1203    }
1204
1205    #[test]
1206    fn idle_timer_never_fires_while_a_connection_is_open() {
1207        let start = Instant::now();
1208        let tracker = IdleTracker::new_at(Some(Duration::from_secs(1)), start);
1209
1210        tracker.begin();
1211        assert!(!tracker.is_idle_at(start + Duration::from_secs(600)));
1212        tracker.end();
1213
1214        tracker.touch_at(start);
1215        assert!(tracker.is_idle_at(start + Duration::from_secs(600)));
1216    }
1217
1218    #[test]
1219    fn activity_resets_the_idle_timer() {
1220        let start = Instant::now();
1221        let tracker = IdleTracker::new_at(Some(Duration::from_secs(10)), start);
1222
1223        tracker.touch_at(start + Duration::from_secs(9));
1224        assert!(!tracker.is_idle_at(start + Duration::from_secs(18)));
1225        assert!(tracker.is_idle_at(start + Duration::from_secs(19)));
1226    }
1227
1228    #[test]
1229    fn idle_shutdown_disabled_without_timeout() {
1230        let start = Instant::now();
1231        let tracker = IdleTracker::new_at(None, start);
1232        assert!(!tracker.is_idle_at(start + Duration::from_secs(86_400)));
1233        assert_eq!(tracker.poll_interval(), MAX_IDLE_POLL);
1234    }
1235
1236    #[test]
1237    fn poll_interval_is_bounded() {
1238        let now = Instant::now();
1239        assert_eq!(
1240            IdleTracker::new_at(Some(Duration::from_millis(10)), now).poll_interval(),
1241            MIN_IDLE_POLL
1242        );
1243        assert_eq!(
1244            IdleTracker::new_at(Some(Duration::from_secs(3600)), now).poll_interval(),
1245            MAX_IDLE_POLL
1246        );
1247        assert_eq!(
1248            IdleTracker::new_at(Some(Duration::from_secs(100)), now).poll_interval(),
1249            Duration::from_secs(10)
1250        );
1251    }
1252
1253    #[test]
1254    fn only_repeatable_operations_are_retryable() {
1255        assert!(Request::Status.is_retryable());
1256        assert!(Request::Traverse(TraverseArgs {
1257            entity: "Rust".into(),
1258            depth: 1,
1259            type_filter: None,
1260        })
1261        .is_retryable());
1262
1263        // Replaying this one would duplicate every episode of a conversation.
1264        assert!(!Request::IngestArchive(IngestArchiveArgs {
1265            content: "# log".into(),
1266            session_id: "s1".into(),
1267            log_number: Some(1),
1268            provenance: None,
1269        })
1270        .is_retryable());
1271        assert!(!Request::AddEntity(AddEntityArgs {
1272            name: "Rust".into(),
1273            entity_type: "tool".into(),
1274            abstract_text: "language".into(),
1275            overview: None,
1276            source: None,
1277        })
1278        .is_retryable());
1279    }
1280
1281    #[test]
1282    fn wire_limits_are_clamped_into_a_servable_range() {
1283        assert_eq!(clamp_limit(10), 10);
1284        assert_eq!(clamp_limit(MAX_LIMIT), MAX_LIMIT);
1285        // A limit this large overflows the `limit * 4` KNN `ef` computation
1286        // and asks SurrealDB for an unbounded scan.
1287        assert_eq!(clamp_limit(usize::MAX), MAX_LIMIT);
1288        assert_eq!(clamp_limit(0), 1);
1289    }
1290
1291    #[test]
1292    fn wire_depths_are_clamped_into_a_servable_range() {
1293        assert_eq!(clamp_depth(2), 2);
1294        assert_eq!(clamp_depth(MAX_DEPTH), MAX_DEPTH);
1295        assert_eq!(clamp_depth(u32::MAX), MAX_DEPTH);
1296        assert_eq!(clamp_depth(0), 1);
1297    }
1298
1299    #[test]
1300    fn the_staged_socket_sits_beside_the_published_one() {
1301        let socket = Path::new("/run/recall-echo/abc.sock");
1302        let staged = staging_path(socket);
1303        assert_eq!(staged.parent(), socket.parent());
1304        assert_ne!(staged, socket);
1305        // `sockaddr_un.sun_path` is 108 bytes and the published path is capped
1306        // at 100, so the staging name must stay within the remainder.
1307        assert!(staged.as_os_str().len() - socket.as_os_str().len() <= 7);
1308    }
1309
1310    #[test]
1311    fn pidfile_sits_beside_the_socket() {
1312        assert_eq!(
1313            pidfile_path(Path::new("/run/recall-echo/abc.sock")),
1314            PathBuf::from("/run/recall-echo/abc.sock.pid")
1315        );
1316    }
1317
1318    #[test]
1319    fn serve_options_from_config_uses_defaults() {
1320        let dir = tempfile::tempdir().unwrap();
1321        std::fs::write(
1322            dir.path().join(".recall-echo.toml"),
1323            format!(
1324                "[serve]\nsocket_path = \"{}/graph.sock\"\n",
1325                dir.path().display()
1326            ),
1327        )
1328        .unwrap();
1329
1330        let options = ServeOptions::from_config(dir.path(), false).unwrap();
1331        assert_eq!(options.idle_timeout, Some(Duration::from_secs(3600)));
1332        assert!(!options.log_to_stderr);
1333        assert_eq!(options.socket_path, dir.path().join("graph.sock"));
1334    }
1335
1336    #[test]
1337    fn foreground_disables_idle_shutdown() {
1338        let dir = tempfile::tempdir().unwrap();
1339        std::fs::write(
1340            dir.path().join(".recall-echo.toml"),
1341            format!(
1342                "[serve]\nsocket_path = \"{}/graph.sock\"\nidle_timeout_secs = 30\n",
1343                dir.path().display()
1344            ),
1345        )
1346        .unwrap();
1347
1348        let options = ServeOptions::from_config(dir.path(), true).unwrap();
1349        assert_eq!(options.idle_timeout, None);
1350        assert!(options.log_to_stderr);
1351    }
1352
1353    #[test]
1354    fn zero_idle_timeout_disables_idle_shutdown() {
1355        let dir = tempfile::tempdir().unwrap();
1356        std::fs::write(
1357            dir.path().join(".recall-echo.toml"),
1358            format!(
1359                "[serve]\nsocket_path = \"{}/graph.sock\"\nidle_timeout_secs = 0\n",
1360                dir.path().display()
1361            ),
1362        )
1363        .unwrap();
1364
1365        assert_eq!(
1366            ServeOptions::from_config(dir.path(), false)
1367                .unwrap()
1368                .idle_timeout,
1369            None
1370        );
1371    }
1372}