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