Skip to main content

recall_echo/
serve.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! `recall-echo serve` — the graph daemon.
6//!
7//! One daemon per memory directory owns the embedded graph store and answers
8//! command-level requests over a unix socket, one JSON object per line:
9//!
10//! ```text
11//! → {"op":"search","args":{"query":"rust","limit":5}}
12//! ← {"ok":true,"data":[ ... ]}
13//! ```
14//!
15//! The daemon is *crash-only*: it keeps no state outside the database, so it
16//! can be killed at any instant. Clients ([`crate::serve_client`]) detect the
17//! dead socket, clean it up and start a fresh daemon.
18//!
19//! Unix only — the socket is a plain `UnixListener`, with no transport
20//! abstraction (see RE-29 decisions log).
21
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicUsize, Ordering};
24use std::sync::{Arc, Mutex};
25use std::time::{Duration, Instant};
26
27use serde::{Deserialize, Serialize};
28use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
29use tokio::net::{UnixListener, UnixStream};
30use tokio::sync::Notify;
31
32use crate::error::RecallError;
33use crate::graph::correct::{CorrectTarget, Correction};
34use crate::graph::error::GraphError;
35use crate::graph::types::{
36    EntityType, NewEntity, NewRelationship, PipelineDocuments, QueryOptions, SearchOptions,
37};
38use crate::graph::utility::OutcomeKind;
39use crate::graph::{GraphMemory, IngestContext, Provenance};
40use crate::serve_security::{
41    append_private_file, check_peer_uid, current_uid, unlink_socket, PRIVATE_FILE_MODE,
42};
43
44/// Longest idle-poll interval; keeps a long-lived daemon from spinning.
45const MAX_IDLE_POLL: Duration = Duration::from_secs(30);
46/// Shortest idle-poll interval; keeps short test timeouts responsive.
47const MIN_IDLE_POLL: Duration = Duration::from_millis(100);
48
49/// Largest request line the daemon will read. An archive ingest is the biggest
50/// legitimate request by far and stays far below this; anything larger is a
51/// buggy or hostile client trying to make the daemon buffer without bound.
52const MAX_REQUEST_BYTES: u64 = 8 * 1024 * 1024;
53/// Largest result set a request may ask for. Wire-supplied limits reach the
54/// HNSW KNN operator, where an unbounded value is an unbounded scan.
55const MAX_LIMIT: usize = 1000;
56/// Deepest graph expansion a request may ask for. Expansion is exponential in
57/// the branching factor.
58const MAX_DEPTH: u32 = 8;
59/// Entities an overview lists per type when the client does not say.
60const DEFAULT_PER_TYPE: usize = 3;
61/// Most entities an overview will list per type. An overview a person cannot
62/// read in ten seconds is not an overview.
63const MAX_PER_TYPE: usize = 20;
64/// How long the daemon waits for its own store to close before giving up and
65/// unlinking the socket anyway.
66const STORE_RELEASE_TIMEOUT: Duration = Duration::from_secs(10);
67/// Polling interval while waiting for connection tasks to release the store.
68const STORE_RELEASE_POLL: Duration = Duration::from_millis(10);
69/// How long the daemon waits for the background extraction worker to stop
70/// before abandoning it and closing up anyway.
71const WORKER_STOP_TIMEOUT: Duration = Duration::from_secs(5);
72
73// ── Protocol ─────────────────────────────────────────────────────────────
74
75/// A client request. Wire form is `{"op": "...", "args": {...}}`.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(tag = "op", content = "args", rename_all = "snake_case")]
78pub enum Request {
79    /// Version handshake — returns [`DaemonInfo`].
80    Hello,
81    /// Graph counts.
82    Status,
83    /// Semantic entity search.
84    Search(SearchArgs),
85    /// Semantic episode search.
86    SearchEpisodes(SearchEpisodesArgs),
87    /// Hybrid query: semantic + graph expansion + optional episodes.
88    Query(QueryArgs),
89    /// Traverse relationships from a named entity.
90    Traverse(TraverseArgs),
91    /// Create an entity.
92    AddEntity(AddEntityArgs),
93    /// Create a relationship between two named entities.
94    Relate(RelateArgs),
95    /// Ingest a conversation archive (episodes only, no LLM extraction).
96    IngestArchive(IngestArchiveArgs),
97    /// Sync the pipeline documents into the graph (no LLM extraction).
98    SyncPipeline(SyncPipelineArgs),
99    /// Apply an outcome to the entities a session touched.
100    Feedback(FeedbackArgs),
101    /// Tell memory that something it learned is wrong.
102    Correct(CorrectArgs),
103    /// Summarise what the graph holds.
104    Overview(OverviewArgs),
105    /// Summarise what the graph holds about one subject.
106    About(AboutArgs),
107    /// Ask the daemon to exit.
108    Shutdown,
109}
110
111impl Request {
112    /// Short name of the operation, for logs.
113    #[must_use]
114    pub fn op_name(&self) -> &'static str {
115        match self {
116            Request::Hello => "hello",
117            Request::Status => "status",
118            Request::Search(_) => "search",
119            Request::SearchEpisodes(_) => "search_episodes",
120            Request::Query(_) => "query",
121            Request::Traverse(_) => "traverse",
122            Request::AddEntity(_) => "add_entity",
123            Request::Relate(_) => "relate",
124            Request::IngestArchive(_) => "ingest_archive",
125            Request::SyncPipeline(_) => "sync_pipeline",
126            Request::Feedback(_) => "feedback",
127            Request::Correct(_) => "correct",
128            Request::Overview(_) => "overview",
129            Request::About(_) => "about",
130            Request::Shutdown => "shutdown",
131        }
132    }
133
134    /// Whether repeating this request against a fresh daemon is safe.
135    ///
136    /// A connection that drops mid-request cannot tell us whether the daemon
137    /// applied it before dying, so only read-only or idempotent operations may
138    /// be retried. Repeating an archive ingest would duplicate its episodes —
139    /// a silently corrupted memory is worse than a reported failure.
140    #[must_use]
141    pub fn is_retryable(&self) -> bool {
142        match self {
143            Request::Hello
144            | Request::Status
145            | Request::Search(_)
146            | Request::SearchEpisodes(_)
147            | Request::Query(_)
148            | Request::Traverse(_)
149            // Pipeline sync diffs documents against the graph.
150            | Request::SyncPipeline(_)
151            // Outcome records replace per (entity, session) — reruns correct.
152            | Request::Feedback(_)
153            | Request::Overview(_)
154            | Request::About(_)
155            | Request::Shutdown => true,
156            // A repeated contradiction is a second observation, not the same
157            // one: replaying it would record evidence the human never gave.
158            Request::AddEntity(_)
159            | Request::Relate(_)
160            | Request::IngestArchive(_)
161            | Request::Correct(_) => false,
162        }
163    }
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub struct SearchArgs {
168    pub query: String,
169    pub limit: usize,
170    #[serde(default)]
171    pub entity_type: Option<String>,
172    #[serde(default)]
173    pub keyword: Option<String>,
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct SearchEpisodesArgs {
178    pub query: String,
179    pub limit: usize,
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183pub struct QueryArgs {
184    pub query: String,
185    pub limit: usize,
186    #[serde(default)]
187    pub entity_type: Option<String>,
188    #[serde(default)]
189    pub keyword: Option<String>,
190    pub depth: u32,
191    pub episodes: bool,
192}
193
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct TraverseArgs {
196    pub entity: String,
197    pub depth: u32,
198    #[serde(default)]
199    pub type_filter: Option<String>,
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203pub struct AddEntityArgs {
204    pub name: String,
205    pub entity_type: String,
206    pub abstract_text: String,
207    #[serde(default)]
208    pub overview: Option<String>,
209    #[serde(default)]
210    pub source: Option<String>,
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214pub struct RelateArgs {
215    pub from: String,
216    pub rel_type: String,
217    pub to: String,
218    #[serde(default)]
219    pub description: Option<String>,
220    #[serde(default)]
221    pub source: Option<String>,
222}
223
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
225pub struct IngestArchiveArgs {
226    pub content: String,
227    pub session_id: String,
228    #[serde(default)]
229    pub log_number: Option<u32>,
230    /// Force one provenance class on every episode of this run. Absent — the
231    /// shape older clients send — means infer per chunk from turn roles.
232    #[serde(default)]
233    pub provenance: Option<Provenance>,
234}
235
236/// Pipeline sync needs no LLM provider, so it runs against the daemon like any
237/// other graph operation instead of taking the store exclusively.
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
239pub struct SyncPipelineArgs {
240    pub docs: PipelineDocuments,
241}
242
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
244pub struct FeedbackArgs {
245    pub session_id: String,
246    pub outcome: OutcomeKind,
247}
248
249/// One human correction: what it is aimed at, and what to do to it.
250#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
251pub struct CorrectArgs {
252    pub target: CorrectTarget,
253    pub correction: Correction,
254}
255
256/// How much of the graph an overview lists per entity type. Absent — the shape
257/// a client that does not care sends — takes the default.
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259pub struct OverviewArgs {
260    #[serde(default)]
261    pub per_type: usize,
262}
263
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265pub struct AboutArgs {
266    pub topic: String,
267    #[serde(default)]
268    pub limit: usize,
269}
270
271/// A daemon response. Wire form is `{"ok": true, "data": ...}` or
272/// `{"ok": false, "error": {"code": "...", "message": "..."}}`.
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274pub struct Response {
275    pub ok: bool,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub data: Option<serde_json::Value>,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub error: Option<ResponseError>,
280}
281
282/// A named failure. `code` is stable and machine-readable; `message` is the
283/// human-readable text (already prefixed by the error kind, e.g. `store locked`).
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285pub struct ResponseError {
286    pub code: String,
287    pub message: String,
288}
289
290impl Response {
291    /// A successful response carrying `data`.
292    #[must_use]
293    pub fn success(data: serde_json::Value) -> Self {
294        Self {
295            ok: true,
296            data: Some(data),
297            error: None,
298        }
299    }
300
301    /// A failed response with a stable `code` and human-readable `message`.
302    #[must_use]
303    pub fn failure(code: impl Into<String>, message: impl Into<String>) -> Self {
304        Self {
305            ok: false,
306            data: None,
307            error: Some(ResponseError {
308                code: code.into(),
309                message: message.into(),
310            }),
311        }
312    }
313
314    /// Convert a graph error into a coded failure response.
315    #[must_use]
316    pub fn from_graph_error(err: &GraphError) -> Self {
317        Self::failure(error_code(err), err.to_string())
318    }
319
320    /// Unwrap into the client-side result: data on success, a named
321    /// [`RecallError::Remote`] on failure.
322    pub fn into_result(self) -> Result<serde_json::Value, RecallError> {
323        if self.ok {
324            return Ok(self.data.unwrap_or(serde_json::Value::Null));
325        }
326        let error = self.error.unwrap_or(ResponseError {
327            code: "unknown".into(),
328            message: "daemon reported failure without a message".into(),
329        });
330        Err(RecallError::Remote {
331            code: error.code,
332            message: error.message,
333        })
334    }
335}
336
337/// Stable machine-readable code for a graph error.
338fn error_code(err: &GraphError) -> &'static str {
339    match err {
340        GraphError::Db(_) => "db",
341        GraphError::Locked(_) => "locked",
342        GraphError::Embed(_) => "embedding",
343        GraphError::NotFound(_) => "not_found",
344        GraphError::Extraction(_) => "extraction",
345        GraphError::Dedup(_) => "dedup",
346        GraphError::Llm(_) => "llm",
347        GraphError::Parse(_) => "parse",
348        GraphError::Io(_) => "io",
349        GraphError::Json(_) => "json",
350        GraphError::ImmutableMerge(_) => "immutable_merge",
351    }
352}
353
354/// Identity of a running daemon, returned by [`Request::Hello`].
355///
356/// `extraction` is additive: a client talking to a daemon that predates
357/// background extraction simply sees the default (disabled, nothing done).
358#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
359pub struct DaemonInfo {
360    pub version: String,
361    pub pid: u32,
362    pub memory_dir: String,
363    pub socket_path: String,
364    pub uptime_secs: u64,
365    #[serde(default)]
366    pub extraction: ExtractionStatus,
367}
368
369/// What this daemon's background extraction worker has done so far.
370#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
371#[serde(default)]
372pub struct ExtractionStatus {
373    /// Whether background extraction is running in this daemon.
374    pub enabled: bool,
375    /// Why it is not running, when it is not.
376    pub disabled_reason: Option<String>,
377    /// Batches that extracted at least one archive.
378    pub runs: u64,
379    /// Archives extracted since the daemon started.
380    pub archives: u64,
381    /// Seconds since the last batch finished.
382    pub last_run_secs_ago: Option<u64>,
383    /// How long the last batch took.
384    pub last_run_ms: Option<u64>,
385    /// The most recent extraction failure, if any.
386    pub last_error: Option<String>,
387}
388
389// ── Dispatch ─────────────────────────────────────────────────────────────
390
391/// Execute a graph operation against an open store.
392///
393/// Control operations ([`Request::Hello`], [`Request::Shutdown`]) are owned by
394/// the connection loop and reported as `unsupported` here.
395pub async fn dispatch_graph(graph: &GraphMemory, request: &Request) -> Response {
396    match execute_graph(graph, request).await {
397        Ok(Some(data)) => Response::success(data),
398        Ok(None) => Response::failure(
399            "unsupported",
400            format!(
401                "`{}` is a control operation, not a graph operation",
402                request.op_name()
403            ),
404        ),
405        Err(err) => Response::from_graph_error(&err),
406    }
407}
408
409/// Clamp a wire-supplied result limit into a range the store can serve.
410fn clamp_limit(limit: usize) -> usize {
411    limit.clamp(1, MAX_LIMIT)
412}
413
414/// Clamp a wire-supplied expansion depth.
415fn clamp_depth(depth: u32) -> u32 {
416    depth.clamp(1, MAX_DEPTH)
417}
418
419/// Clamp a wire-supplied per-type listing size. Zero — what a client that
420/// omits the field sends — means "take the default".
421fn clamp_per_type(per_type: usize) -> usize {
422    if per_type == 0 {
423        return DEFAULT_PER_TYPE;
424    }
425    per_type.clamp(1, MAX_PER_TYPE)
426}
427
428/// `Ok(None)` means "not a graph operation".
429async fn execute_graph(
430    graph: &GraphMemory,
431    request: &Request,
432) -> Result<Option<serde_json::Value>, GraphError> {
433    let data = match request {
434        Request::Hello | Request::Shutdown => return Ok(None),
435        Request::Status => serde_json::to_value(graph.stats().await?)?,
436        Request::Search(args) => {
437            let options = SearchOptions {
438                limit: clamp_limit(args.limit),
439                entity_type: args.entity_type.clone(),
440                keyword: args.keyword.clone(),
441            };
442            serde_json::to_value(graph.search_with_options(&args.query, &options).await?)?
443        }
444        Request::SearchEpisodes(args) => {
445            let mut episodes = graph
446                .search_episodes(&args.query, clamp_limit(args.limit))
447                .await?;
448            for result in &mut episodes {
449                result.episode.embedding = None;
450            }
451            serde_json::to_value(episodes)?
452        }
453        Request::Query(args) => {
454            let options = QueryOptions {
455                limit: clamp_limit(args.limit),
456                entity_type: args.entity_type.clone(),
457                keyword: args.keyword.clone(),
458                graph_depth: clamp_depth(args.depth),
459                include_episodes: args.episodes,
460            };
461            let mut result = graph.query(&args.query, &options).await?;
462            for episode in &mut result.episodes {
463                episode.episode.embedding = None;
464            }
465            serde_json::to_value(result)?
466        }
467        Request::Traverse(args) => serde_json::to_value(
468            graph
469                .traverse_filtered(
470                    &args.entity,
471                    clamp_depth(args.depth),
472                    args.type_filter.as_deref(),
473                )
474                .await?,
475        )?,
476        Request::AddEntity(args) => {
477            let entity_type: EntityType = args
478                .entity_type
479                .parse()
480                .map_err(|e: String| GraphError::Parse(e))?;
481            let mut entity = graph
482                .add_entity(NewEntity {
483                    name: args.name.clone(),
484                    entity_type,
485                    abstract_text: args.abstract_text.clone(),
486                    overview: args.overview.clone(),
487                    content: None,
488                    attributes: None,
489                    source: args.source.clone(),
490                })
491                .await?;
492            // 384 floats of JSON text no client has ever read.
493            entity.embedding = None;
494            serde_json::to_value(entity)?
495        }
496        Request::Relate(args) => {
497            let relationship = graph
498                .add_relationship(NewRelationship {
499                    from_entity: args.from.clone(),
500                    to_entity: args.to.clone(),
501                    rel_type: args.rel_type.clone(),
502                    description: args.description.clone(),
503                    confidence: None,
504                    source: args.source.clone(),
505                })
506                .await?;
507            serde_json::to_value(relationship)?
508        }
509        Request::IngestArchive(args) => {
510            let context = IngestContext::new(args.session_id.clone(), args.log_number)
511                .with_override(args.provenance);
512            let report = graph.ingest_archive(&args.content, &context, None).await?;
513            serde_json::to_value(report)?
514        }
515        Request::SyncPipeline(args) => {
516            serde_json::to_value(graph.sync_pipeline(&args.docs).await?)?
517        }
518        Request::Feedback(args) => serde_json::to_value(
519            graph
520                .record_session_outcome(&args.session_id, args.outcome)
521                .await?,
522        )?,
523        Request::Correct(args) => {
524            serde_json::to_value(graph.correct(&args.target, args.correction).await?)?
525        }
526        Request::Overview(args) => {
527            serde_json::to_value(graph.overview(clamp_per_type(args.per_type)).await?)?
528        }
529        Request::About(args) => {
530            serde_json::to_value(graph.about(&args.topic, clamp_limit(args.limit)).await?)?
531        }
532    };
533    Ok(Some(data))
534}
535
536// ── Idle tracking ────────────────────────────────────────────────────────
537
538/// Tracks daemon activity so an unused daemon shuts itself down, and so
539/// background work only runs while nobody is asking for anything.
540///
541/// Two different questions are asked of the same clock:
542///
543/// - *quiet* — no connection is open and the last request is older than some
544///   period. Background extraction waits for this.
545/// - *idle* — quiet for the configured shutdown timeout, **and** no background
546///   batch in flight. The accept loop exits on this.
547///
548/// A background batch therefore cannot be cut in half by the idle timeout, and
549/// a hot request always makes the daemon un-quiet immediately. `None` disables
550/// idle shutdown entirely (`--foreground`, or `idle_timeout_secs = 0`) without
551/// disabling quiet, which is what keeps a supervised daemon extracting.
552#[derive(Debug)]
553pub struct IdleTracker {
554    timeout: Option<Duration>,
555    active: AtomicUsize,
556    background: AtomicUsize,
557    last_activity: Mutex<Instant>,
558}
559
560impl IdleTracker {
561    #[must_use]
562    pub fn new(timeout: Option<Duration>) -> Self {
563        Self::new_at(timeout, Instant::now())
564    }
565
566    /// Construct with an explicit start instant (used by tests).
567    #[must_use]
568    pub fn new_at(timeout: Option<Duration>, start: Instant) -> Self {
569        Self {
570            timeout,
571            active: AtomicUsize::new(0),
572            background: AtomicUsize::new(0),
573            last_activity: Mutex::new(start),
574        }
575    }
576
577    /// Register a connection as open.
578    pub fn begin(&self) {
579        self.active.fetch_add(1, Ordering::SeqCst);
580        self.touch_at(Instant::now());
581    }
582
583    /// Register a connection as closed.
584    pub fn end(&self) {
585        let previous = self.active.fetch_sub(1, Ordering::SeqCst);
586        debug_assert!(previous > 0, "IdleTracker::end without begin");
587        self.touch_at(Instant::now());
588    }
589
590    /// Register a background batch as running. Holds off idle shutdown until
591    /// it finishes, without pretending the daemon is being used.
592    pub fn begin_background(&self) {
593        self.background.fetch_add(1, Ordering::SeqCst);
594    }
595
596    /// Register a background batch as finished.
597    pub fn end_background(&self) {
598        let previous = self.background.fetch_sub(1, Ordering::SeqCst);
599        debug_assert!(previous > 0, "IdleTracker::end_background without begin");
600    }
601
602    /// Record activity at `now`.
603    pub fn touch_at(&self, now: Instant) {
604        let mut last = self
605            .last_activity
606            .lock()
607            .unwrap_or_else(|poisoned| poisoned.into_inner());
608        *last = now;
609    }
610
611    /// True while at least one client connection is open.
612    #[must_use]
613    pub fn has_connections(&self) -> bool {
614        self.active.load(Ordering::SeqCst) > 0
615    }
616
617    /// True when no connection is open and nothing has been asked of the
618    /// daemon for `quiet`.
619    #[must_use]
620    pub fn is_quiet_at(&self, now: Instant, quiet: Duration) -> bool {
621        if self.has_connections() {
622            return false;
623        }
624        let last = *self
625            .last_activity
626            .lock()
627            .unwrap_or_else(|poisoned| poisoned.into_inner());
628        now.saturating_duration_since(last) >= quiet
629    }
630
631    /// True when the daemon has been unused for longer than the timeout and no
632    /// background batch is in flight.
633    #[must_use]
634    pub fn is_idle_at(&self, now: Instant) -> bool {
635        let Some(timeout) = self.timeout else {
636            return false;
637        };
638        if self.background.load(Ordering::SeqCst) > 0 {
639            return false;
640        }
641        self.is_quiet_at(now, timeout)
642    }
643
644    /// The configured idle shutdown timeout, if any.
645    #[must_use]
646    pub fn timeout(&self) -> Option<Duration> {
647        self.timeout
648    }
649
650    /// How often the accept loop should re-check idleness.
651    #[must_use]
652    pub fn poll_interval(&self) -> Duration {
653        match self.timeout {
654            None => MAX_IDLE_POLL,
655            Some(timeout) => (timeout / 10).clamp(MIN_IDLE_POLL, MAX_IDLE_POLL),
656        }
657    }
658}
659
660/// RAII connection counter for [`IdleTracker`].
661struct ActivityGuard(Arc<DaemonContext>);
662
663impl ActivityGuard {
664    fn new(context: Arc<DaemonContext>) -> Self {
665        context.idle.begin();
666        Self(context)
667    }
668}
669
670impl Drop for ActivityGuard {
671    fn drop(&mut self) {
672        self.0.idle.end();
673    }
674}
675
676/// RAII background-batch counter for [`IdleTracker`].
677///
678/// Held for exactly one batch, so an interrupted daemon waits for the unit in
679/// flight rather than for the whole backlog.
680pub struct BackgroundGuard(Arc<IdleTracker>);
681
682impl BackgroundGuard {
683    #[must_use]
684    pub fn new(idle: Arc<IdleTracker>) -> Self {
685        idle.begin_background();
686        Self(idle)
687    }
688}
689
690impl Drop for BackgroundGuard {
691    fn drop(&mut self) {
692        self.0.end_background();
693    }
694}
695
696// ── Shutdown ─────────────────────────────────────────────────────────────
697
698/// One-way "stop now" signal, observable by any number of tasks.
699///
700/// A latched flag rather than a bare [`Notify`]: a task that checks after the
701/// signal fired must still see it, or a worker between two units would sleep
702/// through the daemon's exit and hold the store open.
703#[derive(Debug, Default)]
704pub struct ShutdownSignal {
705    triggered: std::sync::atomic::AtomicBool,
706    notify: Notify,
707}
708
709impl ShutdownSignal {
710    #[must_use]
711    pub fn new() -> Self {
712        Self::default()
713    }
714
715    /// Ask everything watching to stop. Idempotent.
716    pub fn trigger(&self) {
717        self.triggered.store(true, Ordering::SeqCst);
718        self.notify.notify_waiters();
719    }
720
721    #[must_use]
722    pub fn is_triggered(&self) -> bool {
723        self.triggered.load(Ordering::SeqCst)
724    }
725
726    /// Resolve when shutdown is requested — immediately if it already was.
727    pub async fn wait(&self) {
728        loop {
729            // Register before reading the flag: the reverse order loses a
730            // `trigger` that lands in between, and the waiter never wakes.
731            let notified = self.notify.notified();
732            tokio::pin!(notified);
733            notified.as_mut().enable();
734            if self.is_triggered() {
735                return;
736            }
737            notified.await;
738        }
739    }
740
741    /// Run `future` unless shutdown arrives first. `None` means it did.
742    pub async fn guard<F: std::future::Future>(&self, future: F) -> Option<F::Output> {
743        tokio::select! {
744            biased;
745            () = self.wait() => None,
746            output = future => Some(output),
747        }
748    }
749
750    /// Sleep for `duration`. `true` means shutdown cut it short.
751    pub async fn sleep_until_stopped(&self, duration: Duration) -> bool {
752        self.guard(tokio::time::sleep(duration)).await.is_none()
753    }
754}
755
756// ── Background extraction state ──────────────────────────────────────────
757
758/// What the background extraction worker has done, shared with the connection
759/// tasks that answer [`Request::Hello`].
760#[derive(Debug, Default)]
761pub struct ExtractionState {
762    inner: Mutex<ExtractionProgress>,
763}
764
765#[derive(Debug, Default)]
766struct ExtractionProgress {
767    enabled: bool,
768    disabled_reason: Option<String>,
769    runs: u64,
770    archives: u64,
771    last_run: Option<Instant>,
772    last_run_ms: Option<u64>,
773    last_error: Option<String>,
774}
775
776impl ExtractionState {
777    #[must_use]
778    pub fn shared() -> Arc<Self> {
779        Arc::new(Self::default())
780    }
781
782    fn with<T>(&self, apply: impl FnOnce(&mut ExtractionProgress) -> T) -> T {
783        let mut progress = self
784            .inner
785            .lock()
786            .unwrap_or_else(|poisoned| poisoned.into_inner());
787        apply(&mut progress)
788    }
789
790    /// Mark the worker as running.
791    pub fn enable(&self) {
792        self.with(|progress| {
793            progress.enabled = true;
794            progress.disabled_reason = None;
795        });
796    }
797
798    /// Mark the worker as not running, and why.
799    pub fn disable(&self, reason: impl Into<String>) {
800        self.with(|progress| {
801            progress.enabled = false;
802            progress.disabled_reason = Some(reason.into());
803        });
804    }
805
806    /// Record a batch that extracted `archives` archives in `elapsed`.
807    pub fn record_batch(&self, archives: u64, elapsed: Duration, finished_at: Instant) {
808        self.with(|progress| {
809            progress.runs += 1;
810            progress.archives += archives;
811            progress.last_run = Some(finished_at);
812            progress.last_run_ms = Some(elapsed.as_millis() as u64);
813        });
814    }
815
816    /// Record the most recent failure, replacing any earlier one.
817    pub fn record_error(&self, error: impl Into<String>) {
818        self.with(|progress| progress.last_error = Some(error.into()));
819    }
820
821    /// Wire-facing snapshot as of `now`.
822    #[must_use]
823    pub fn snapshot(&self, now: Instant) -> ExtractionStatus {
824        self.with(|progress| ExtractionStatus {
825            enabled: progress.enabled,
826            disabled_reason: progress.disabled_reason.clone(),
827            runs: progress.runs,
828            archives: progress.archives,
829            last_run_secs_ago: progress
830                .last_run
831                .map(|at| now.saturating_duration_since(at).as_secs()),
832            last_run_ms: progress.last_run_ms,
833            last_error: progress.last_error.clone(),
834        })
835    }
836}
837
838// ── Logging ──────────────────────────────────────────────────────────────
839
840/// Append-only daemon log at `<memory_dir>/graph/daemon.log`.
841///
842/// Never fails a request: if the file cannot be opened, lines go to stderr.
843pub struct DaemonLog {
844    file: Mutex<Option<std::fs::File>>,
845    echo_stderr: bool,
846}
847
848impl DaemonLog {
849    #[must_use]
850    pub fn open(path: &Path, echo_stderr: bool) -> Self {
851        let file = append_private_file().open(path).ok();
852        Self {
853            file: Mutex::new(file),
854            echo_stderr,
855        }
856    }
857
858    pub fn log(&self, message: &str) {
859        use std::io::Write as _;
860        let line = format!(
861            "[{}] pid={} {message}\n",
862            chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"),
863            std::process::id(),
864        );
865        let mut guard = self
866            .file
867            .lock()
868            .unwrap_or_else(|poisoned| poisoned.into_inner());
869        match guard.as_mut() {
870            Some(file) => {
871                let _ = file.write_all(line.as_bytes());
872                let _ = file.flush();
873                if self.echo_stderr {
874                    eprint!("{line}");
875                }
876            }
877            None => eprint!("{line}"),
878        }
879    }
880}
881
882// ── Daemon ───────────────────────────────────────────────────────────────
883
884/// Everything `serve` needs to run.
885#[derive(Debug, Clone)]
886pub struct ServeOptions {
887    /// The memory directory whose `graph/` store this daemon owns.
888    pub memory_dir: PathBuf,
889    /// Unix socket to listen on.
890    pub socket_path: PathBuf,
891    /// Idle shutdown timeout; `None` never shuts down.
892    pub idle_timeout: Option<Duration>,
893    /// Mirror the daemon log to stderr (foreground / systemd mode).
894    pub log_to_stderr: bool,
895}
896
897impl ServeOptions {
898    /// Build options from `[serve]` in the memory directory's config.
899    ///
900    /// `foreground` (systemd) disables idle shutdown and mirrors the log to
901    /// stderr, leaving daemon lifetime to the supervisor.
902    pub fn from_config(memory_dir: &Path, foreground: bool) -> Result<Self, RecallError> {
903        let config = crate::config::load_from_dir(memory_dir);
904        let idle_timeout = match (foreground, config.serve.idle_timeout_secs) {
905            (true, _) | (_, 0) => None,
906            (false, secs) => Some(Duration::from_secs(secs)),
907        };
908        Ok(Self {
909            memory_dir: memory_dir.to_path_buf(),
910            socket_path: crate::serve_client::socket_path(memory_dir)?,
911            idle_timeout,
912            log_to_stderr: foreground,
913        })
914    }
915}
916
917/// Shared, immutable-ish daemon state.
918struct DaemonContext {
919    started: Instant,
920    memory_dir: PathBuf,
921    socket_path: PathBuf,
922    /// Only this uid may use the socket.
923    owner_uid: u32,
924    idle: Arc<IdleTracker>,
925    shutdown: Arc<ShutdownSignal>,
926    extraction: Arc<ExtractionState>,
927}
928
929impl DaemonContext {
930    fn info(&self) -> DaemonInfo {
931        DaemonInfo {
932            version: env!("CARGO_PKG_VERSION").to_string(),
933            pid: std::process::id(),
934            memory_dir: self.memory_dir.display().to_string(),
935            socket_path: self.socket_path.display().to_string(),
936            uptime_secs: self.started.elapsed().as_secs(),
937            extraction: self.extraction.snapshot(Instant::now()),
938        }
939    }
940}
941
942/// Path of the pidfile that accompanies a socket.
943#[must_use]
944pub fn pidfile_path(socket_path: &Path) -> PathBuf {
945    let mut path = socket_path.as_os_str().to_os_string();
946    path.push(".pid");
947    PathBuf::from(path)
948}
949
950/// Run the daemon until it is asked to stop or goes idle.
951pub async fn run(options: ServeOptions) -> Result<(), RecallError> {
952    let graph_dir = options.memory_dir.join("graph");
953    std::fs::create_dir_all(&graph_dir)?;
954
955    let log = Arc::new(DaemonLog::open(
956        &graph_dir.join("daemon.log"),
957        options.log_to_stderr,
958    ));
959    log.log(&format!(
960        "starting v{} for {} on {}",
961        env!("CARGO_PKG_VERSION"),
962        options.memory_dir.display(),
963        options.socket_path.display()
964    ));
965
966    if crate::serve_client::graph_mode(&options.memory_dir) == "server" {
967        log.log("warning: [graph] mode = \"server\" — clients bypass the daemon");
968    }
969
970    let owner_uid = current_uid()?;
971
972    // Own the store before advertising the socket: clients that connect only
973    // after a successful bind never see a half-initialized daemon.
974    let graph = open_store(&graph_dir, &options.socket_path, &log).await?;
975
976    let listener = match bind_socket(&options.socket_path) {
977        Ok(listener) => listener,
978        Err(err) => {
979            log.log(&format!("failed to bind socket: {err}"));
980            return Err(err);
981        }
982    };
983    write_pidfile(&options.socket_path)?;
984
985    let extraction = ExtractionState::shared();
986    let context = Arc::new(DaemonContext {
987        started: Instant::now(),
988        memory_dir: options.memory_dir.clone(),
989        socket_path: options.socket_path.clone(),
990        owner_uid,
991        idle: Arc::new(IdleTracker::new(options.idle_timeout)),
992        shutdown: Arc::new(ShutdownSignal::new()),
993        extraction: Arc::clone(&extraction),
994    });
995    warm_embedder(Arc::clone(&graph), Arc::clone(&log));
996    let worker = start_background_extraction(&options, &graph, &context, &log);
997    let capture = start_background_capture(&options, &graph, &context, &log);
998    log.log("ready");
999
1000    accept_loop(
1001        listener,
1002        Arc::clone(&graph),
1003        Arc::clone(&context),
1004        Arc::clone(&log),
1005    )
1006    .await;
1007
1008    // Whatever ended the accept loop — a shutdown request, an idle timeout —
1009    // ends the background worker too, and it must let go of the store before
1010    // we try to close it.
1011    context.shutdown.trigger();
1012    stop_background_extraction(worker, &log).await;
1013    stop_background_capture(capture, &log).await;
1014
1015    // Close the store *before* the socket disappears: a client waiting for the
1016    // socket to go treats that as "the store is free", and would otherwise
1017    // race the SurrealKV file lock we have not released yet.
1018    release_store(graph, &log).await;
1019    if let Err(err) = unlink_socket(&options.socket_path) {
1020        log.log(&format!("socket cleanup: {err}"));
1021    }
1022    let _ = std::fs::remove_file(pidfile_path(&options.socket_path));
1023    log.log("stopped");
1024    Ok(())
1025}
1026
1027/// Open the store, waiting out an admin operation that currently owns it.
1028async fn open_store(
1029    graph_dir: &Path,
1030    socket_path: &Path,
1031    log: &DaemonLog,
1032) -> Result<Arc<GraphMemory>, RecallError> {
1033    let deadline = Instant::now() + crate::serve_client::ADMIN_WAIT_TIMEOUT;
1034    loop {
1035        crate::serve_client::wait_for_admin_lock(socket_path, deadline).await?;
1036        match GraphMemory::open_embedded(graph_dir).await {
1037            Ok(graph) => return Ok(Arc::new(graph)),
1038            Err(GraphError::Locked(message))
1039                if Instant::now() < deadline
1040                    && crate::serve_client::admin_lock_is_held(socket_path) =>
1041            {
1042                log.log(&format!("waiting for an admin operation: {message}"));
1043            }
1044            Err(err) => {
1045                log.log(&format!("failed to open graph store: {err}"));
1046                return Err(err.into());
1047            }
1048        }
1049    }
1050}
1051
1052/// Drop the store once every in-flight connection has let go of it.
1053async fn release_store(graph: Arc<GraphMemory>, log: &DaemonLog) {
1054    let deadline = Instant::now() + STORE_RELEASE_TIMEOUT;
1055    let mut graph = graph;
1056    loop {
1057        match Arc::try_unwrap(graph) {
1058            Ok(store) => {
1059                drop(store);
1060                return;
1061            }
1062            Err(shared) => {
1063                if Instant::now() >= deadline {
1064                    log.log("gave up waiting for in-flight requests to release the store");
1065                    return;
1066                }
1067                graph = shared;
1068                tokio::time::sleep(STORE_RELEASE_POLL).await;
1069            }
1070        }
1071    }
1072}
1073
1074/// Load the ONNX embedding model in the background, so the first request that
1075/// needs an embedding does not pay for it inline.
1076///
1077/// Skipped while the model cache is empty: warming a cold cache downloads the
1078/// model, which must stay tied to a request that actually needs it rather than
1079/// happening on every daemon start.
1080fn warm_embedder(graph: Arc<GraphMemory>, log: Arc<DaemonLog>) {
1081    let models_dir = graph.path().join("models");
1082    if !has_cached_model(&models_dir) {
1083        return;
1084    }
1085    tokio::task::spawn_blocking(move || {
1086        let started = Instant::now();
1087        match graph.embedder() {
1088            Ok(_) => log.log(&format!(
1089                "embedder warm in {}ms",
1090                started.elapsed().as_millis()
1091            )),
1092            Err(err) => log.log(&format!("embedder warm-up failed: {err}")),
1093        }
1094    });
1095}
1096
1097/// Start the background extraction worker, when this build and this config
1098/// allow one. `None` means no worker runs; the reason is in the log and in
1099/// [`ExtractionStatus::disabled_reason`].
1100#[cfg(feature = "llm")]
1101fn start_background_extraction(
1102    options: &ServeOptions,
1103    graph: &Arc<GraphMemory>,
1104    context: &Arc<DaemonContext>,
1105    log: &Arc<DaemonLog>,
1106) -> Option<tokio::task::JoinHandle<()>> {
1107    crate::serve_extract::spawn(crate::serve_extract::Setup {
1108        memory_dir: options.memory_dir.clone(),
1109        graph: Arc::clone(graph),
1110        idle: Arc::clone(&context.idle),
1111        shutdown: Arc::clone(&context.shutdown),
1112        state: Arc::clone(&context.extraction),
1113        log: Arc::clone(log),
1114    })
1115}
1116
1117#[cfg(not(feature = "llm"))]
1118fn start_background_extraction(
1119    _options: &ServeOptions,
1120    _graph: &Arc<GraphMemory>,
1121    context: &Arc<DaemonContext>,
1122    log: &Arc<DaemonLog>,
1123) -> Option<tokio::task::JoinHandle<()>> {
1124    context
1125        .extraction
1126        .disable("this binary was built without the `llm` feature");
1127    log.log("background extraction off: built without the `llm` feature");
1128    None
1129}
1130
1131/// Start the background transcript-capture worker, when this config wants one.
1132///
1133/// Independent of extraction on purpose: a user with no LLM provider still gets
1134/// their Codex and Grok sessions archived, and a user who has turned capture
1135/// off still gets entities extracted.
1136fn start_background_capture(
1137    options: &ServeOptions,
1138    graph: &Arc<GraphMemory>,
1139    context: &Arc<DaemonContext>,
1140    log: &Arc<DaemonLog>,
1141) -> Option<tokio::task::JoinHandle<()>> {
1142    crate::serve_capture::spawn(crate::serve_capture::Setup {
1143        memory_dir: options.memory_dir.clone(),
1144        graph: Arc::clone(graph),
1145        idle: Arc::clone(&context.idle),
1146        shutdown: Arc::clone(&context.shutdown),
1147        log: Arc::clone(log),
1148    })
1149}
1150
1151/// Wait for the capture worker to finish the transcript in flight.
1152async fn stop_background_capture(worker: Option<tokio::task::JoinHandle<()>>, log: &DaemonLog) {
1153    let Some(mut worker) = worker else {
1154        return;
1155    };
1156    if tokio::time::timeout(WORKER_STOP_TIMEOUT, &mut worker)
1157        .await
1158        .is_err()
1159    {
1160        worker.abort();
1161        log.log("background capture did not stop in time — abandoned mid-transcript");
1162    }
1163}
1164
1165/// Wait for the background worker to let go of the store, then take it away.
1166///
1167/// The worker stops between units on its own; the timeout covers the case
1168/// where it is inside a synchronous stretch (the ONNX embedder) that no
1169/// cancellation point can interrupt.
1170async fn stop_background_extraction(worker: Option<tokio::task::JoinHandle<()>>, log: &DaemonLog) {
1171    let Some(mut worker) = worker else {
1172        return;
1173    };
1174    if tokio::time::timeout(WORKER_STOP_TIMEOUT, &mut worker)
1175        .await
1176        .is_err()
1177    {
1178        worker.abort();
1179        log.log("background extraction did not stop in time — abandoned mid-archive");
1180    }
1181}
1182
1183fn has_cached_model(models_dir: &Path) -> bool {
1184    std::fs::read_dir(models_dir).is_ok_and(|mut entries| entries.next().is_some())
1185}
1186
1187async fn accept_loop(
1188    listener: UnixListener,
1189    graph: Arc<GraphMemory>,
1190    context: Arc<DaemonContext>,
1191    log: Arc<DaemonLog>,
1192) {
1193    loop {
1194        let poll = context.idle.poll_interval();
1195        tokio::select! {
1196            accepted = listener.accept() => match accepted {
1197                Ok((stream, _)) => {
1198                    let graph = Arc::clone(&graph);
1199                    let context = Arc::clone(&context);
1200                    let log = Arc::clone(&log);
1201                    tokio::spawn(async move {
1202                        handle_connection(stream, graph, context, log).await;
1203                    });
1204                }
1205                Err(err) => log.log(&format!("accept error: {err}")),
1206            },
1207            () = context.shutdown.wait() => {
1208                log.log("shutdown requested");
1209                break;
1210            }
1211            () = tokio::time::sleep(poll) => {
1212                if context.idle.is_idle_at(Instant::now()) {
1213                    log.log("idle timeout — exiting");
1214                    break;
1215                }
1216            }
1217        }
1218    }
1219}
1220
1221async fn handle_connection(
1222    stream: UnixStream,
1223    graph: Arc<GraphMemory>,
1224    context: Arc<DaemonContext>,
1225    log: Arc<DaemonLog>,
1226) {
1227    let _activity = ActivityGuard::new(Arc::clone(&context));
1228    if let Err(err) = authorize_peer(&stream, context.owner_uid) {
1229        log.log(&format!("rejected connection: {err}"));
1230        return;
1231    }
1232
1233    let (reader, mut writer) = stream.into_split();
1234    let mut lines = BufReader::new(reader.take(MAX_REQUEST_BYTES)).lines();
1235
1236    loop {
1237        let line = match lines.next_line().await {
1238            Ok(Some(line)) => line,
1239            Ok(None) => {
1240                // The reader stopped at the byte cap instead of at a newline:
1241                // the client is sending a request larger than we will read.
1242                if request_cap_reached(&mut lines) {
1243                    let response = Response::failure(
1244                        "bad_request",
1245                        format!("request exceeds the {MAX_REQUEST_BYTES}-byte limit"),
1246                    );
1247                    log.log("rejected an oversized request");
1248                    let _ = write_response(&mut writer, &response).await;
1249                }
1250                break;
1251            }
1252            Err(err) => {
1253                log.log(&format!("read error: {err}"));
1254                break;
1255            }
1256        };
1257        recharge_request_cap(&mut lines);
1258        if line.trim().is_empty() {
1259            continue;
1260        }
1261
1262        let (response, stop) = match serde_json::from_str::<Request>(&line) {
1263            Ok(Request::Hello) => (
1264                Response::success(
1265                    serde_json::to_value(context.info()).unwrap_or(serde_json::Value::Null),
1266                ),
1267                false,
1268            ),
1269            Ok(Request::Shutdown) => (
1270                Response::success(serde_json::json!({ "stopping": true })),
1271                true,
1272            ),
1273            Ok(request) => {
1274                let started = Instant::now();
1275                let response = dispatch_graph(&graph, &request).await;
1276                log.log(&format!(
1277                    "{} {} in {}ms",
1278                    request.op_name(),
1279                    if response.ok { "ok" } else { "failed" },
1280                    started.elapsed().as_millis()
1281                ));
1282                (response, false)
1283            }
1284            Err(err) => (
1285                Response::failure("bad_request", format!("malformed request: {err}")),
1286                false,
1287            ),
1288        };
1289
1290        if let Err(err) = write_response(&mut writer, &response).await {
1291            log.log(&format!("write error: {err}"));
1292            break;
1293        }
1294        context.idle.touch_at(Instant::now());
1295
1296        if stop {
1297            // The signal latches, so a wakeup that lands while the accept loop
1298            // is between `select!` iterations is still seen on the next one:
1299            // the daemon can never outlive its own shutdown request.
1300            context.shutdown.trigger();
1301            break;
1302        }
1303    }
1304}
1305
1306/// A connection's request reader, capped at [`MAX_REQUEST_BYTES`] per request.
1307type RequestLines = tokio::io::Lines<BufReader<tokio::io::Take<tokio::net::unix::OwnedReadHalf>>>;
1308
1309/// True when the reader stopped because the request hit the byte cap.
1310fn request_cap_reached(lines: &mut RequestLines) -> bool {
1311    lines.get_mut().get_mut().limit() == 0
1312}
1313
1314/// Give the next request on this connection its own full byte budget.
1315fn recharge_request_cap(lines: &mut RequestLines) {
1316    lines.get_mut().get_mut().set_limit(MAX_REQUEST_BYTES);
1317}
1318
1319/// The socket has no authentication of its own: anyone who can open it can
1320/// read every ingest payload and forge every answer. Only our own uid may.
1321fn authorize_peer(stream: &UnixStream, owner_uid: u32) -> Result<(), RecallError> {
1322    let peer = stream.peer_cred().map_err(|err| {
1323        RecallError::Daemon(format!("cannot read socket peer credentials: {err}"))
1324    })?;
1325    check_peer_uid(peer.uid(), owner_uid)
1326}
1327
1328async fn write_response(
1329    writer: &mut tokio::net::unix::OwnedWriteHalf,
1330    response: &Response,
1331) -> Result<(), RecallError> {
1332    let mut line = serde_json::to_vec(response)?;
1333    line.push(b'\n');
1334    writer.write_all(&line).await?;
1335    writer.flush().await?;
1336    Ok(())
1337}
1338
1339/// Path the socket is bound to before it is published under its real name.
1340fn staging_path(socket_path: &Path) -> PathBuf {
1341    let mut path = socket_path.as_os_str().to_os_string();
1342    path.push(".new");
1343    PathBuf::from(path)
1344}
1345
1346/// Bind the listening socket, clearing a stale socket left by a dead daemon.
1347///
1348/// The socket is bound under a temporary name in the same directory, made
1349/// owner-only, and only then renamed into place: `bind` applies the process
1350/// umask, so publishing first would leave a world-reachable socket for as long
1351/// as it takes to chmod it.
1352fn bind_socket(socket_path: &Path) -> Result<UnixListener, RecallError> {
1353    use std::os::unix::fs::PermissionsExt;
1354
1355    if let Some(parent) = socket_path.parent() {
1356        crate::serve_client::ensure_socket_dir(parent)?;
1357    }
1358    if std::os::unix::net::UnixStream::connect(socket_path).is_ok() {
1359        return Err(RecallError::Daemon(format!(
1360            "another daemon is already listening on {}",
1361            socket_path.display()
1362        )));
1363    }
1364
1365    let staged = staging_path(socket_path);
1366    unlink_socket(&staged)?;
1367    let listener = UnixListener::bind(&staged).map_err(|err| {
1368        RecallError::Daemon(format!("cannot listen on {}: {err}", staged.display()))
1369    })?;
1370    std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(PRIVATE_FILE_MODE)).map_err(
1371        |err| {
1372            RecallError::Daemon(format!(
1373                "cannot restrict the daemon socket {}: {err}",
1374                staged.display()
1375            ))
1376        },
1377    )?;
1378
1379    // Whatever sits at the published path is a socket a dead daemon left.
1380    unlink_socket(socket_path)?;
1381    std::fs::rename(&staged, socket_path).map_err(|err| {
1382        let _ = std::fs::remove_file(&staged);
1383        RecallError::Daemon(format!(
1384            "cannot publish the daemon socket at {}: {err}",
1385            socket_path.display()
1386        ))
1387    })?;
1388    Ok(listener)
1389}
1390
1391fn write_pidfile(socket_path: &Path) -> Result<(), RecallError> {
1392    use std::io::Write as _;
1393
1394    let contents = serde_json::json!({
1395        "pid": std::process::id(),
1396        "version": env!("CARGO_PKG_VERSION"),
1397        "socket_path": socket_path.display().to_string(),
1398    });
1399    let path = pidfile_path(socket_path);
1400    let _ = std::fs::remove_file(&path);
1401    let mut file = crate::serve_security::create_new_private_file().open(&path)?;
1402    writeln!(file, "{contents}")?;
1403    Ok(())
1404}
1405
1406#[cfg(test)]
1407mod tests {
1408    use super::*;
1409
1410    const fn assert_shareable<T: Send + Sync>() {}
1411
1412    #[test]
1413    fn graph_memory_is_shareable_across_tasks() {
1414        // The daemon hands one Arc<GraphMemory> to every connection task.
1415        assert_shareable::<GraphMemory>();
1416    }
1417
1418    #[test]
1419    fn request_wire_format_is_op_and_args() {
1420        let request = Request::Search(SearchArgs {
1421            query: "rust".into(),
1422            limit: 5,
1423            entity_type: None,
1424            keyword: None,
1425        });
1426        let json = serde_json::to_value(&request).unwrap();
1427        assert_eq!(json["op"], "search");
1428        assert_eq!(json["args"]["query"], "rust");
1429        assert_eq!(json["args"]["limit"], 5);
1430    }
1431
1432    #[test]
1433    fn control_requests_serialize_without_args() {
1434        assert_eq!(
1435            serde_json::to_string(&Request::Hello).unwrap(),
1436            r#"{"op":"hello"}"#
1437        );
1438        assert_eq!(
1439            serde_json::to_string(&Request::Shutdown).unwrap(),
1440            r#"{"op":"shutdown"}"#
1441        );
1442    }
1443
1444    #[test]
1445    fn request_round_trips_every_variant() {
1446        let requests = vec![
1447            Request::Hello,
1448            Request::Status,
1449            Request::Search(SearchArgs {
1450                query: "q".into(),
1451                limit: 3,
1452                entity_type: Some("tool".into()),
1453                keyword: Some("k".into()),
1454            }),
1455            Request::SearchEpisodes(SearchEpisodesArgs {
1456                query: "q".into(),
1457                limit: 2,
1458            }),
1459            Request::Query(QueryArgs {
1460                query: "q".into(),
1461                limit: 10,
1462                entity_type: None,
1463                keyword: None,
1464                depth: 2,
1465                episodes: true,
1466            }),
1467            Request::Traverse(TraverseArgs {
1468                entity: "Rust".into(),
1469                depth: 1,
1470                type_filter: None,
1471            }),
1472            Request::AddEntity(AddEntityArgs {
1473                name: "Rust".into(),
1474                entity_type: "tool".into(),
1475                abstract_text: "language".into(),
1476                overview: None,
1477                source: Some("test".into()),
1478            }),
1479            Request::Relate(RelateArgs {
1480                from: "D".into(),
1481                rel_type: "USES".into(),
1482                to: "Rust".into(),
1483                description: None,
1484                source: None,
1485            }),
1486            Request::IngestArchive(IngestArchiveArgs {
1487                content: "# log".into(),
1488                session_id: "s1".into(),
1489                log_number: Some(7),
1490                provenance: Some(Provenance::External),
1491            }),
1492            Request::SyncPipeline(SyncPipelineArgs {
1493                docs: PipelineDocuments {
1494                    learning: "# learning".into(),
1495                    ..PipelineDocuments::default()
1496                },
1497            }),
1498            Request::Feedback(FeedbackArgs {
1499                session_id: "s1".into(),
1500                outcome: OutcomeKind::Success,
1501            }),
1502            Request::Correct(CorrectArgs {
1503                target: CorrectTarget::Edge {
1504                    from: "D".into(),
1505                    rel_type: "USES".into(),
1506                    to: "Vim".into(),
1507                },
1508                correction: Correction::Wrong { all_edges: false },
1509            }),
1510            Request::Correct(CorrectArgs {
1511                target: CorrectTarget::Entity { name: "Vim".into() },
1512                correction: Correction::Forget { confirmed: true },
1513            }),
1514            Request::Overview(OverviewArgs { per_type: 3 }),
1515            Request::About(AboutArgs {
1516                topic: "rust".into(),
1517                limit: 5,
1518            }),
1519            Request::Shutdown,
1520        ];
1521
1522        for request in requests {
1523            let line = serde_json::to_string(&request).unwrap();
1524            let parsed: Request = serde_json::from_str(&line).unwrap();
1525            assert_eq!(parsed, request, "round trip failed for {line}");
1526        }
1527    }
1528
1529    #[test]
1530    fn ingest_requests_without_provenance_still_parse() {
1531        // The wire shape older clients send: absent means "infer from turn
1532        // roles", so a pre-provenance client keeps working unchanged.
1533        let parsed: Request = serde_json::from_str(
1534            r##"{"op":"ingest_archive","args":{"content":"# log","session_id":"s1"}}"##,
1535        )
1536        .unwrap();
1537        assert_eq!(
1538            parsed,
1539            Request::IngestArchive(IngestArchiveArgs {
1540                content: "# log".into(),
1541                session_id: "s1".into(),
1542                log_number: None,
1543                provenance: None,
1544            })
1545        );
1546    }
1547
1548    #[test]
1549    fn feedback_is_a_hot_op_with_a_snake_case_outcome() {
1550        let request = Request::Feedback(FeedbackArgs {
1551            session_id: "conversation-042".into(),
1552            outcome: OutcomeKind::Failed,
1553        });
1554        let json = serde_json::to_value(&request).unwrap();
1555        assert_eq!(json["op"], "feedback");
1556        assert_eq!(json["args"]["session_id"], "conversation-042");
1557        assert_eq!(json["args"]["outcome"], "failed");
1558        assert_eq!(request.op_name(), "feedback");
1559    }
1560
1561    #[test]
1562    fn optional_args_may_be_omitted_on_the_wire() {
1563        let parsed: Request =
1564            serde_json::from_str(r#"{"op":"search","args":{"query":"q","limit":1}}"#).unwrap();
1565        assert_eq!(
1566            parsed,
1567            Request::Search(SearchArgs {
1568                query: "q".into(),
1569                limit: 1,
1570                entity_type: None,
1571                keyword: None,
1572            })
1573        );
1574    }
1575
1576    #[test]
1577    fn success_response_carries_data_only() {
1578        let response = Response::success(serde_json::json!({"n": 1}));
1579        let json = serde_json::to_value(&response).unwrap();
1580        assert_eq!(json["ok"], true);
1581        assert_eq!(json["data"]["n"], 1);
1582        assert!(json.get("error").is_none());
1583        assert_eq!(response.into_result().unwrap(), serde_json::json!({"n": 1}));
1584    }
1585
1586    #[test]
1587    fn failure_response_maps_to_named_remote_error() {
1588        let response = Response::from_graph_error(&GraphError::Locked("store busy".into()));
1589        assert_eq!(response.error.as_ref().unwrap().code, "locked");
1590        let err = response.into_result().unwrap_err();
1591        assert!(err.to_string().contains("store locked"));
1592        assert!(matches!(err, RecallError::Remote { .. }));
1593    }
1594
1595    #[test]
1596    fn failure_response_round_trips() {
1597        let response = Response::failure("bad_request", "malformed");
1598        let line = serde_json::to_string(&response).unwrap();
1599        let parsed: Response = serde_json::from_str(&line).unwrap();
1600        assert_eq!(parsed, response);
1601        assert!(!parsed.ok);
1602    }
1603
1604    #[test]
1605    fn error_codes_are_distinct_per_kind() {
1606        assert_eq!(error_code(&GraphError::Locked(String::new())), "locked");
1607        assert_eq!(
1608            error_code(&GraphError::NotFound(String::new())),
1609            "not_found"
1610        );
1611        assert_eq!(error_code(&GraphError::Embed(String::new())), "embedding");
1612    }
1613
1614    #[test]
1615    fn idle_timer_fires_after_timeout() {
1616        let start = Instant::now();
1617        let tracker = IdleTracker::new_at(Some(Duration::from_secs(60)), start);
1618
1619        assert!(!tracker.is_idle_at(start + Duration::from_secs(59)));
1620        assert!(tracker.is_idle_at(start + Duration::from_secs(60)));
1621    }
1622
1623    #[test]
1624    fn idle_timer_never_fires_while_a_connection_is_open() {
1625        let start = Instant::now();
1626        let tracker = IdleTracker::new_at(Some(Duration::from_secs(1)), start);
1627
1628        tracker.begin();
1629        assert!(!tracker.is_idle_at(start + Duration::from_secs(600)));
1630        tracker.end();
1631
1632        tracker.touch_at(start);
1633        assert!(tracker.is_idle_at(start + Duration::from_secs(600)));
1634    }
1635
1636    #[test]
1637    fn activity_resets_the_idle_timer() {
1638        let start = Instant::now();
1639        let tracker = IdleTracker::new_at(Some(Duration::from_secs(10)), start);
1640
1641        tracker.touch_at(start + Duration::from_secs(9));
1642        assert!(!tracker.is_idle_at(start + Duration::from_secs(18)));
1643        assert!(tracker.is_idle_at(start + Duration::from_secs(19)));
1644    }
1645
1646    /// A background batch must not be cut in half by the idle timeout — and
1647    /// must not postpone shutdown once it is done, or the daemon would never
1648    /// exit again.
1649    #[test]
1650    fn a_batch_in_flight_defers_idle_shutdown() {
1651        let start = Instant::now();
1652        let tracker = Arc::new(IdleTracker::new_at(Some(Duration::from_secs(60)), start));
1653        let later = start + Duration::from_secs(600);
1654        assert!(tracker.is_idle_at(later));
1655
1656        let guard = BackgroundGuard::new(Arc::clone(&tracker));
1657        assert!(!tracker.is_idle_at(later), "idle exit cut a batch in half");
1658        // Background work is not activity: the daemon is still unused, which
1659        // is what lets the *next* batch start.
1660        assert!(tracker.is_quiet_at(later, Duration::from_secs(60)));
1661
1662        drop(guard);
1663        assert!(tracker.is_idle_at(later));
1664    }
1665
1666    /// `--foreground` disables idle shutdown, not background work: a
1667    /// supervised daemon still has quiet periods to extract in.
1668    #[test]
1669    fn quiet_is_independent_of_the_idle_timeout() {
1670        let start = Instant::now();
1671        let tracker = IdleTracker::new_at(None, start);
1672        let later = start + Duration::from_secs(300);
1673
1674        assert!(!tracker.is_idle_at(later));
1675        assert!(tracker.is_quiet_at(later, Duration::from_secs(120)));
1676    }
1677
1678    #[test]
1679    fn an_open_connection_is_never_quiet() {
1680        let start = Instant::now();
1681        let tracker = IdleTracker::new_at(Some(Duration::from_secs(60)), start);
1682        let later = start + Duration::from_secs(600);
1683
1684        tracker.begin();
1685        assert!(tracker.has_connections());
1686        assert!(!tracker.is_quiet_at(later, Duration::from_secs(1)));
1687
1688        tracker.end();
1689        tracker.touch_at(start);
1690        assert!(tracker.is_quiet_at(later, Duration::from_secs(1)));
1691    }
1692
1693    /// The signal latches: a task that checks after it fired still sees it.
1694    /// Without that, a worker between two units sleeps through the daemon's
1695    /// exit and keeps the store open.
1696    #[tokio::test]
1697    async fn the_shutdown_signal_latches_for_late_waiters() {
1698        let signal = ShutdownSignal::new();
1699        assert!(!signal.is_triggered());
1700        signal.trigger();
1701        assert!(signal.is_triggered());
1702
1703        tokio::time::timeout(Duration::from_secs(5), signal.wait())
1704            .await
1705            .expect("a waiter that arrives after the trigger still wakes");
1706        assert!(signal.guard(std::future::pending::<()>()).await.is_none());
1707        assert!(signal.sleep_until_stopped(Duration::from_secs(600)).await);
1708    }
1709
1710    #[tokio::test]
1711    async fn the_shutdown_signal_lets_work_finish_when_it_is_not_triggered() {
1712        let signal = ShutdownSignal::new();
1713        assert_eq!(signal.guard(async { 7 }).await, Some(7));
1714        assert!(!signal.sleep_until_stopped(Duration::from_millis(1)).await);
1715    }
1716
1717    #[test]
1718    fn extraction_status_reports_progress_and_refusals() {
1719        let state = ExtractionState::shared();
1720        let now = Instant::now();
1721        assert!(!state.snapshot(now).enabled);
1722
1723        state.enable();
1724        state.record_batch(3, Duration::from_millis(250), now);
1725        let status = state.snapshot(now + Duration::from_secs(9));
1726        assert!(status.enabled);
1727        assert_eq!(status.runs, 1);
1728        assert_eq!(status.archives, 3);
1729        assert_eq!(status.last_run_secs_ago, Some(9));
1730        assert_eq!(status.last_run_ms, Some(250));
1731
1732        state.disable("no usable LLM provider");
1733        let status = state.snapshot(now);
1734        assert!(!status.enabled);
1735        assert_eq!(
1736            status.disabled_reason.as_deref(),
1737            Some("no usable LLM provider")
1738        );
1739        // What it managed to do before it stopped is still worth reporting.
1740        assert_eq!(status.archives, 3);
1741    }
1742
1743    /// A client built before background extraction sends no `extraction` field.
1744    #[test]
1745    fn daemon_info_without_extraction_still_parses() {
1746        let info: DaemonInfo = serde_json::from_str(
1747            r#"{"version":"3.0.0","pid":1,"memory_dir":"/m","socket_path":"/s","uptime_secs":5}"#,
1748        )
1749        .expect("older daemon info");
1750        assert_eq!(info.extraction, ExtractionStatus::default());
1751        assert!(!info.extraction.enabled);
1752    }
1753
1754    #[test]
1755    fn idle_shutdown_disabled_without_timeout() {
1756        let start = Instant::now();
1757        let tracker = IdleTracker::new_at(None, start);
1758        assert!(!tracker.is_idle_at(start + Duration::from_secs(86_400)));
1759        assert_eq!(tracker.poll_interval(), MAX_IDLE_POLL);
1760    }
1761
1762    #[test]
1763    fn poll_interval_is_bounded() {
1764        let now = Instant::now();
1765        assert_eq!(
1766            IdleTracker::new_at(Some(Duration::from_millis(10)), now).poll_interval(),
1767            MIN_IDLE_POLL
1768        );
1769        assert_eq!(
1770            IdleTracker::new_at(Some(Duration::from_secs(3600)), now).poll_interval(),
1771            MAX_IDLE_POLL
1772        );
1773        assert_eq!(
1774            IdleTracker::new_at(Some(Duration::from_secs(100)), now).poll_interval(),
1775            Duration::from_secs(10)
1776        );
1777    }
1778
1779    #[test]
1780    fn only_repeatable_operations_are_retryable() {
1781        assert!(Request::Status.is_retryable());
1782        assert!(Request::Traverse(TraverseArgs {
1783            entity: "Rust".into(),
1784            depth: 1,
1785            type_filter: None,
1786        })
1787        .is_retryable());
1788
1789        // Replaying this one would duplicate every episode of a conversation.
1790        assert!(!Request::IngestArchive(IngestArchiveArgs {
1791            content: "# log".into(),
1792            session_id: "s1".into(),
1793            log_number: Some(1),
1794            provenance: None,
1795        })
1796        .is_retryable());
1797        assert!(!Request::AddEntity(AddEntityArgs {
1798            name: "Rust".into(),
1799            entity_type: "tool".into(),
1800            abstract_text: "language".into(),
1801            overview: None,
1802            source: None,
1803        })
1804        .is_retryable());
1805    }
1806
1807    #[test]
1808    fn wire_limits_are_clamped_into_a_servable_range() {
1809        assert_eq!(clamp_limit(10), 10);
1810        assert_eq!(clamp_limit(MAX_LIMIT), MAX_LIMIT);
1811        // A limit this large overflows the `limit * 4` KNN `ef` computation
1812        // and asks SurrealDB for an unbounded scan.
1813        assert_eq!(clamp_limit(usize::MAX), MAX_LIMIT);
1814        assert_eq!(clamp_limit(0), 1);
1815    }
1816
1817    /// A correction is an observation. Replaying one because a connection
1818    /// dropped would record evidence the human never gave.
1819    #[test]
1820    fn a_correction_is_never_replayed() {
1821        assert!(!Request::Correct(CorrectArgs {
1822            target: CorrectTarget::Entity { name: "Vim".into() },
1823            correction: Correction::Wrong { all_edges: false },
1824        })
1825        .is_retryable());
1826        assert!(Request::Overview(OverviewArgs { per_type: 0 }).is_retryable());
1827        assert!(Request::About(AboutArgs {
1828            topic: "rust".into(),
1829            limit: 0,
1830        })
1831        .is_retryable());
1832    }
1833
1834    /// The shape a client that does not care about listing size sends.
1835    #[test]
1836    fn inspection_args_may_be_omitted_on_the_wire() {
1837        assert_eq!(
1838            serde_json::from_str::<Request>(r#"{"op":"overview","args":{}}"#).unwrap(),
1839            Request::Overview(OverviewArgs { per_type: 0 })
1840        );
1841        assert_eq!(
1842            serde_json::from_str::<Request>(r#"{"op":"about","args":{"topic":"rust"}}"#).unwrap(),
1843            Request::About(AboutArgs {
1844                topic: "rust".into(),
1845                limit: 0,
1846            })
1847        );
1848        assert_eq!(
1849            serde_json::from_str::<Request>(
1850                r#"{"op":"correct","args":{"target":{"kind":"entity","name":"Vim"},
1851                    "correction":{"kind":"wrong"}}}"#
1852            )
1853            .unwrap(),
1854            Request::Correct(CorrectArgs {
1855                target: CorrectTarget::Entity { name: "Vim".into() },
1856                correction: Correction::Wrong { all_edges: false },
1857            })
1858        );
1859    }
1860
1861    #[test]
1862    fn an_absent_per_type_takes_the_default() {
1863        assert_eq!(clamp_per_type(0), DEFAULT_PER_TYPE);
1864        assert_eq!(clamp_per_type(5), 5);
1865        assert_eq!(clamp_per_type(usize::MAX), MAX_PER_TYPE);
1866    }
1867
1868    #[test]
1869    fn wire_depths_are_clamped_into_a_servable_range() {
1870        assert_eq!(clamp_depth(2), 2);
1871        assert_eq!(clamp_depth(MAX_DEPTH), MAX_DEPTH);
1872        assert_eq!(clamp_depth(u32::MAX), MAX_DEPTH);
1873        assert_eq!(clamp_depth(0), 1);
1874    }
1875
1876    #[test]
1877    fn the_staged_socket_sits_beside_the_published_one() {
1878        let socket = Path::new("/run/recall-echo/abc.sock");
1879        let staged = staging_path(socket);
1880        assert_eq!(staged.parent(), socket.parent());
1881        assert_ne!(staged, socket);
1882        // `sockaddr_un.sun_path` is 108 bytes and the published path is capped
1883        // at 100, so the staging name must stay within the remainder.
1884        assert!(staged.as_os_str().len() - socket.as_os_str().len() <= 7);
1885    }
1886
1887    #[test]
1888    fn pidfile_sits_beside_the_socket() {
1889        assert_eq!(
1890            pidfile_path(Path::new("/run/recall-echo/abc.sock")),
1891            PathBuf::from("/run/recall-echo/abc.sock.pid")
1892        );
1893    }
1894
1895    #[test]
1896    fn serve_options_from_config_uses_defaults() {
1897        let dir = tempfile::tempdir().unwrap();
1898        std::fs::write(
1899            dir.path().join(".recall-echo.toml"),
1900            format!(
1901                "[serve]\nsocket_path = \"{}/graph.sock\"\n",
1902                dir.path().display()
1903            ),
1904        )
1905        .unwrap();
1906
1907        let options = ServeOptions::from_config(dir.path(), false).unwrap();
1908        assert_eq!(options.idle_timeout, Some(Duration::from_secs(3600)));
1909        assert!(!options.log_to_stderr);
1910        assert_eq!(options.socket_path, dir.path().join("graph.sock"));
1911    }
1912
1913    #[test]
1914    fn foreground_disables_idle_shutdown() {
1915        let dir = tempfile::tempdir().unwrap();
1916        std::fs::write(
1917            dir.path().join(".recall-echo.toml"),
1918            format!(
1919                "[serve]\nsocket_path = \"{}/graph.sock\"\nidle_timeout_secs = 30\n",
1920                dir.path().display()
1921            ),
1922        )
1923        .unwrap();
1924
1925        let options = ServeOptions::from_config(dir.path(), true).unwrap();
1926        assert_eq!(options.idle_timeout, None);
1927        assert!(options.log_to_stderr);
1928    }
1929
1930    #[test]
1931    fn zero_idle_timeout_disables_idle_shutdown() {
1932        let dir = tempfile::tempdir().unwrap();
1933        std::fs::write(
1934            dir.path().join(".recall-echo.toml"),
1935            format!(
1936                "[serve]\nsocket_path = \"{}/graph.sock\"\nidle_timeout_secs = 0\n",
1937                dir.path().display()
1938            ),
1939        )
1940        .unwrap();
1941
1942        assert_eq!(
1943            ServeOptions::from_config(dir.path(), false)
1944                .unwrap()
1945                .idle_timeout,
1946            None
1947        );
1948    }
1949}