1use std::path::{Path, PathBuf};
19use std::sync::atomic::{AtomicUsize, Ordering};
20use std::sync::{Arc, Mutex};
21use std::time::{Duration, Instant};
22
23use serde::{Deserialize, Serialize};
24use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
25use tokio::net::{UnixListener, UnixStream};
26use tokio::sync::Notify;
27
28use crate::error::RecallError;
29use crate::graph::error::GraphError;
30use crate::graph::types::{
31 EntityType, NewEntity, NewRelationship, PipelineDocuments, QueryOptions, SearchOptions,
32};
33use crate::graph::utility::OutcomeKind;
34use crate::graph::{GraphMemory, IngestContext, Provenance};
35use crate::serve_security::{
36 append_private_file, check_peer_uid, current_uid, unlink_socket, PRIVATE_FILE_MODE,
37};
38
39const MAX_IDLE_POLL: Duration = Duration::from_secs(30);
41const MIN_IDLE_POLL: Duration = Duration::from_millis(100);
43
44const MAX_REQUEST_BYTES: u64 = 8 * 1024 * 1024;
48const MAX_LIMIT: usize = 1000;
51const MAX_DEPTH: u32 = 8;
54const STORE_RELEASE_TIMEOUT: Duration = Duration::from_secs(10);
57const STORE_RELEASE_POLL: Duration = Duration::from_millis(10);
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(tag = "op", content = "args", rename_all = "snake_case")]
65pub enum Request {
66 Hello,
68 Status,
70 Search(SearchArgs),
72 SearchEpisodes(SearchEpisodesArgs),
74 Query(QueryArgs),
76 Traverse(TraverseArgs),
78 AddEntity(AddEntityArgs),
80 Relate(RelateArgs),
82 IngestArchive(IngestArchiveArgs),
84 SyncPipeline(SyncPipelineArgs),
86 Feedback(FeedbackArgs),
88 Shutdown,
90}
91
92impl Request {
93 #[must_use]
95 pub fn op_name(&self) -> &'static str {
96 match self {
97 Request::Hello => "hello",
98 Request::Status => "status",
99 Request::Search(_) => "search",
100 Request::SearchEpisodes(_) => "search_episodes",
101 Request::Query(_) => "query",
102 Request::Traverse(_) => "traverse",
103 Request::AddEntity(_) => "add_entity",
104 Request::Relate(_) => "relate",
105 Request::IngestArchive(_) => "ingest_archive",
106 Request::SyncPipeline(_) => "sync_pipeline",
107 Request::Feedback(_) => "feedback",
108 Request::Shutdown => "shutdown",
109 }
110 }
111
112 #[must_use]
119 pub fn is_retryable(&self) -> bool {
120 match self {
121 Request::Hello
122 | Request::Status
123 | Request::Search(_)
124 | Request::SearchEpisodes(_)
125 | Request::Query(_)
126 | Request::Traverse(_)
127 | Request::SyncPipeline(_)
129 | Request::Feedback(_)
131 | Request::Shutdown => true,
132 Request::AddEntity(_) | Request::Relate(_) | Request::IngestArchive(_) => false,
133 }
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct SearchArgs {
139 pub query: String,
140 pub limit: usize,
141 #[serde(default)]
142 pub entity_type: Option<String>,
143 #[serde(default)]
144 pub keyword: Option<String>,
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct SearchEpisodesArgs {
149 pub query: String,
150 pub limit: usize,
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct QueryArgs {
155 pub query: String,
156 pub limit: usize,
157 #[serde(default)]
158 pub entity_type: Option<String>,
159 #[serde(default)]
160 pub keyword: Option<String>,
161 pub depth: u32,
162 pub episodes: bool,
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct TraverseArgs {
167 pub entity: String,
168 pub depth: u32,
169 #[serde(default)]
170 pub type_filter: Option<String>,
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174pub struct AddEntityArgs {
175 pub name: String,
176 pub entity_type: String,
177 pub abstract_text: String,
178 #[serde(default)]
179 pub overview: Option<String>,
180 #[serde(default)]
181 pub source: Option<String>,
182}
183
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185pub struct RelateArgs {
186 pub from: String,
187 pub rel_type: String,
188 pub to: String,
189 #[serde(default)]
190 pub description: Option<String>,
191 #[serde(default)]
192 pub source: Option<String>,
193}
194
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196pub struct IngestArchiveArgs {
197 pub content: String,
198 pub session_id: String,
199 #[serde(default)]
200 pub log_number: Option<u32>,
201 #[serde(default)]
204 pub provenance: Option<Provenance>,
205}
206
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
210pub struct SyncPipelineArgs {
211 pub docs: PipelineDocuments,
212}
213
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215pub struct FeedbackArgs {
216 pub session_id: String,
217 pub outcome: OutcomeKind,
218}
219
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223pub struct Response {
224 pub ok: bool,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub data: Option<serde_json::Value>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub error: Option<ResponseError>,
229}
230
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234pub struct ResponseError {
235 pub code: String,
236 pub message: String,
237}
238
239impl Response {
240 #[must_use]
242 pub fn success(data: serde_json::Value) -> Self {
243 Self {
244 ok: true,
245 data: Some(data),
246 error: None,
247 }
248 }
249
250 #[must_use]
252 pub fn failure(code: impl Into<String>, message: impl Into<String>) -> Self {
253 Self {
254 ok: false,
255 data: None,
256 error: Some(ResponseError {
257 code: code.into(),
258 message: message.into(),
259 }),
260 }
261 }
262
263 #[must_use]
265 pub fn from_graph_error(err: &GraphError) -> Self {
266 Self::failure(error_code(err), err.to_string())
267 }
268
269 pub fn into_result(self) -> Result<serde_json::Value, RecallError> {
272 if self.ok {
273 return Ok(self.data.unwrap_or(serde_json::Value::Null));
274 }
275 let error = self.error.unwrap_or(ResponseError {
276 code: "unknown".into(),
277 message: "daemon reported failure without a message".into(),
278 });
279 Err(RecallError::Remote {
280 code: error.code,
281 message: error.message,
282 })
283 }
284}
285
286fn error_code(err: &GraphError) -> &'static str {
288 match err {
289 GraphError::Db(_) => "db",
290 GraphError::Locked(_) => "locked",
291 GraphError::Embed(_) => "embedding",
292 GraphError::NotFound(_) => "not_found",
293 GraphError::Extraction(_) => "extraction",
294 GraphError::Dedup(_) => "dedup",
295 GraphError::Llm(_) => "llm",
296 GraphError::Parse(_) => "parse",
297 GraphError::Io(_) => "io",
298 GraphError::Json(_) => "json",
299 GraphError::ImmutableMerge(_) => "immutable_merge",
300 }
301}
302
303#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
305pub struct DaemonInfo {
306 pub version: String,
307 pub pid: u32,
308 pub memory_dir: String,
309 pub socket_path: String,
310 pub uptime_secs: u64,
311}
312
313pub async fn dispatch_graph(graph: &GraphMemory, request: &Request) -> Response {
320 match execute_graph(graph, request).await {
321 Ok(Some(data)) => Response::success(data),
322 Ok(None) => Response::failure(
323 "unsupported",
324 format!(
325 "`{}` is a control operation, not a graph operation",
326 request.op_name()
327 ),
328 ),
329 Err(err) => Response::from_graph_error(&err),
330 }
331}
332
333fn clamp_limit(limit: usize) -> usize {
335 limit.clamp(1, MAX_LIMIT)
336}
337
338fn clamp_depth(depth: u32) -> u32 {
340 depth.clamp(1, MAX_DEPTH)
341}
342
343async fn execute_graph(
345 graph: &GraphMemory,
346 request: &Request,
347) -> Result<Option<serde_json::Value>, GraphError> {
348 let data = match request {
349 Request::Hello | Request::Shutdown => return Ok(None),
350 Request::Status => serde_json::to_value(graph.stats().await?)?,
351 Request::Search(args) => {
352 let options = SearchOptions {
353 limit: clamp_limit(args.limit),
354 entity_type: args.entity_type.clone(),
355 keyword: args.keyword.clone(),
356 };
357 serde_json::to_value(graph.search_with_options(&args.query, &options).await?)?
358 }
359 Request::SearchEpisodes(args) => {
360 let mut episodes = graph
361 .search_episodes(&args.query, clamp_limit(args.limit))
362 .await?;
363 for result in &mut episodes {
364 result.episode.embedding = None;
365 }
366 serde_json::to_value(episodes)?
367 }
368 Request::Query(args) => {
369 let options = QueryOptions {
370 limit: clamp_limit(args.limit),
371 entity_type: args.entity_type.clone(),
372 keyword: args.keyword.clone(),
373 graph_depth: clamp_depth(args.depth),
374 include_episodes: args.episodes,
375 };
376 let mut result = graph.query(&args.query, &options).await?;
377 for episode in &mut result.episodes {
378 episode.episode.embedding = None;
379 }
380 serde_json::to_value(result)?
381 }
382 Request::Traverse(args) => serde_json::to_value(
383 graph
384 .traverse_filtered(
385 &args.entity,
386 clamp_depth(args.depth),
387 args.type_filter.as_deref(),
388 )
389 .await?,
390 )?,
391 Request::AddEntity(args) => {
392 let entity_type: EntityType = args
393 .entity_type
394 .parse()
395 .map_err(|e: String| GraphError::Parse(e))?;
396 let mut entity = graph
397 .add_entity(NewEntity {
398 name: args.name.clone(),
399 entity_type,
400 abstract_text: args.abstract_text.clone(),
401 overview: args.overview.clone(),
402 content: None,
403 attributes: None,
404 source: args.source.clone(),
405 })
406 .await?;
407 entity.embedding = None;
409 serde_json::to_value(entity)?
410 }
411 Request::Relate(args) => {
412 let relationship = graph
413 .add_relationship(NewRelationship {
414 from_entity: args.from.clone(),
415 to_entity: args.to.clone(),
416 rel_type: args.rel_type.clone(),
417 description: args.description.clone(),
418 confidence: None,
419 source: args.source.clone(),
420 })
421 .await?;
422 serde_json::to_value(relationship)?
423 }
424 Request::IngestArchive(args) => {
425 let context = IngestContext::new(args.session_id.clone(), args.log_number)
426 .with_override(args.provenance);
427 let report = graph.ingest_archive(&args.content, &context, None).await?;
428 serde_json::to_value(report)?
429 }
430 Request::SyncPipeline(args) => {
431 serde_json::to_value(graph.sync_pipeline(&args.docs).await?)?
432 }
433 Request::Feedback(args) => serde_json::to_value(
434 graph
435 .record_session_outcome(&args.session_id, args.outcome)
436 .await?,
437 )?,
438 };
439 Ok(Some(data))
440}
441
442#[derive(Debug)]
450pub struct IdleTracker {
451 timeout: Option<Duration>,
452 active: AtomicUsize,
453 last_activity: Mutex<Instant>,
454}
455
456impl IdleTracker {
457 #[must_use]
458 pub fn new(timeout: Option<Duration>) -> Self {
459 Self::new_at(timeout, Instant::now())
460 }
461
462 #[must_use]
464 pub fn new_at(timeout: Option<Duration>, start: Instant) -> Self {
465 Self {
466 timeout,
467 active: AtomicUsize::new(0),
468 last_activity: Mutex::new(start),
469 }
470 }
471
472 pub fn begin(&self) {
474 self.active.fetch_add(1, Ordering::SeqCst);
475 self.touch_at(Instant::now());
476 }
477
478 pub fn end(&self) {
480 let previous = self.active.fetch_sub(1, Ordering::SeqCst);
481 debug_assert!(previous > 0, "IdleTracker::end without begin");
482 self.touch_at(Instant::now());
483 }
484
485 pub fn touch_at(&self, now: Instant) {
487 let mut last = self
488 .last_activity
489 .lock()
490 .unwrap_or_else(|poisoned| poisoned.into_inner());
491 *last = now;
492 }
493
494 #[must_use]
496 pub fn is_idle_at(&self, now: Instant) -> bool {
497 let Some(timeout) = self.timeout else {
498 return false;
499 };
500 if self.active.load(Ordering::SeqCst) > 0 {
501 return false;
502 }
503 let last = *self
504 .last_activity
505 .lock()
506 .unwrap_or_else(|poisoned| poisoned.into_inner());
507 now.saturating_duration_since(last) >= timeout
508 }
509
510 #[must_use]
512 pub fn poll_interval(&self) -> Duration {
513 match self.timeout {
514 None => MAX_IDLE_POLL,
515 Some(timeout) => (timeout / 10).clamp(MIN_IDLE_POLL, MAX_IDLE_POLL),
516 }
517 }
518}
519
520struct ActivityGuard(Arc<DaemonContext>);
522
523impl ActivityGuard {
524 fn new(context: Arc<DaemonContext>) -> Self {
525 context.idle.begin();
526 Self(context)
527 }
528}
529
530impl Drop for ActivityGuard {
531 fn drop(&mut self) {
532 self.0.idle.end();
533 }
534}
535
536pub struct DaemonLog {
542 file: Mutex<Option<std::fs::File>>,
543 echo_stderr: bool,
544}
545
546impl DaemonLog {
547 #[must_use]
548 pub fn open(path: &Path, echo_stderr: bool) -> Self {
549 let file = append_private_file().open(path).ok();
550 Self {
551 file: Mutex::new(file),
552 echo_stderr,
553 }
554 }
555
556 pub fn log(&self, message: &str) {
557 use std::io::Write as _;
558 let line = format!(
559 "[{}] pid={} {message}\n",
560 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"),
561 std::process::id(),
562 );
563 let mut guard = self
564 .file
565 .lock()
566 .unwrap_or_else(|poisoned| poisoned.into_inner());
567 match guard.as_mut() {
568 Some(file) => {
569 let _ = file.write_all(line.as_bytes());
570 let _ = file.flush();
571 if self.echo_stderr {
572 eprint!("{line}");
573 }
574 }
575 None => eprint!("{line}"),
576 }
577 }
578}
579
580#[derive(Debug, Clone)]
584pub struct ServeOptions {
585 pub memory_dir: PathBuf,
587 pub socket_path: PathBuf,
589 pub idle_timeout: Option<Duration>,
591 pub log_to_stderr: bool,
593}
594
595impl ServeOptions {
596 pub fn from_config(memory_dir: &Path, foreground: bool) -> Result<Self, RecallError> {
601 let config = crate::config::load_from_dir(memory_dir);
602 let idle_timeout = match (foreground, config.serve.idle_timeout_secs) {
603 (true, _) | (_, 0) => None,
604 (false, secs) => Some(Duration::from_secs(secs)),
605 };
606 Ok(Self {
607 memory_dir: memory_dir.to_path_buf(),
608 socket_path: crate::serve_client::socket_path(memory_dir)?,
609 idle_timeout,
610 log_to_stderr: foreground,
611 })
612 }
613}
614
615struct DaemonContext {
617 started: Instant,
618 memory_dir: PathBuf,
619 socket_path: PathBuf,
620 owner_uid: u32,
622 idle: IdleTracker,
623 shutdown: Notify,
624}
625
626impl DaemonContext {
627 fn info(&self) -> DaemonInfo {
628 DaemonInfo {
629 version: env!("CARGO_PKG_VERSION").to_string(),
630 pid: std::process::id(),
631 memory_dir: self.memory_dir.display().to_string(),
632 socket_path: self.socket_path.display().to_string(),
633 uptime_secs: self.started.elapsed().as_secs(),
634 }
635 }
636}
637
638#[must_use]
640pub fn pidfile_path(socket_path: &Path) -> PathBuf {
641 let mut path = socket_path.as_os_str().to_os_string();
642 path.push(".pid");
643 PathBuf::from(path)
644}
645
646pub async fn run(options: ServeOptions) -> Result<(), RecallError> {
648 let graph_dir = options.memory_dir.join("graph");
649 std::fs::create_dir_all(&graph_dir)?;
650
651 let log = Arc::new(DaemonLog::open(
652 &graph_dir.join("daemon.log"),
653 options.log_to_stderr,
654 ));
655 log.log(&format!(
656 "starting v{} for {} on {}",
657 env!("CARGO_PKG_VERSION"),
658 options.memory_dir.display(),
659 options.socket_path.display()
660 ));
661
662 if crate::serve_client::graph_mode(&options.memory_dir) == "server" {
663 log.log("warning: [graph] mode = \"server\" — clients bypass the daemon");
664 }
665
666 let owner_uid = current_uid()?;
667
668 let graph = open_store(&graph_dir, &options.socket_path, &log).await?;
671
672 let listener = match bind_socket(&options.socket_path) {
673 Ok(listener) => listener,
674 Err(err) => {
675 log.log(&format!("failed to bind socket: {err}"));
676 return Err(err);
677 }
678 };
679 write_pidfile(&options.socket_path)?;
680
681 let context = Arc::new(DaemonContext {
682 started: Instant::now(),
683 memory_dir: options.memory_dir.clone(),
684 socket_path: options.socket_path.clone(),
685 owner_uid,
686 idle: IdleTracker::new(options.idle_timeout),
687 shutdown: Notify::new(),
688 });
689 warm_embedder(Arc::clone(&graph), Arc::clone(&log));
690 log.log("ready");
691
692 accept_loop(
693 listener,
694 Arc::clone(&graph),
695 Arc::clone(&context),
696 Arc::clone(&log),
697 )
698 .await;
699
700 release_store(graph, &log).await;
704 if let Err(err) = unlink_socket(&options.socket_path) {
705 log.log(&format!("socket cleanup: {err}"));
706 }
707 let _ = std::fs::remove_file(pidfile_path(&options.socket_path));
708 log.log("stopped");
709 Ok(())
710}
711
712async fn open_store(
714 graph_dir: &Path,
715 socket_path: &Path,
716 log: &DaemonLog,
717) -> Result<Arc<GraphMemory>, RecallError> {
718 let deadline = Instant::now() + crate::serve_client::ADMIN_WAIT_TIMEOUT;
719 loop {
720 crate::serve_client::wait_for_admin_lock(socket_path, deadline).await?;
721 match GraphMemory::open_embedded(graph_dir).await {
722 Ok(graph) => return Ok(Arc::new(graph)),
723 Err(GraphError::Locked(message))
724 if Instant::now() < deadline
725 && crate::serve_client::admin_lock_is_held(socket_path) =>
726 {
727 log.log(&format!("waiting for an admin operation: {message}"));
728 }
729 Err(err) => {
730 log.log(&format!("failed to open graph store: {err}"));
731 return Err(err.into());
732 }
733 }
734 }
735}
736
737async fn release_store(graph: Arc<GraphMemory>, log: &DaemonLog) {
739 let deadline = Instant::now() + STORE_RELEASE_TIMEOUT;
740 let mut graph = graph;
741 loop {
742 match Arc::try_unwrap(graph) {
743 Ok(store) => {
744 drop(store);
745 return;
746 }
747 Err(shared) => {
748 if Instant::now() >= deadline {
749 log.log("gave up waiting for in-flight requests to release the store");
750 return;
751 }
752 graph = shared;
753 tokio::time::sleep(STORE_RELEASE_POLL).await;
754 }
755 }
756 }
757}
758
759fn warm_embedder(graph: Arc<GraphMemory>, log: Arc<DaemonLog>) {
766 let models_dir = graph.path().join("models");
767 if !has_cached_model(&models_dir) {
768 return;
769 }
770 tokio::task::spawn_blocking(move || {
771 let started = Instant::now();
772 match graph.embedder() {
773 Ok(_) => log.log(&format!(
774 "embedder warm in {}ms",
775 started.elapsed().as_millis()
776 )),
777 Err(err) => log.log(&format!("embedder warm-up failed: {err}")),
778 }
779 });
780}
781
782fn has_cached_model(models_dir: &Path) -> bool {
783 std::fs::read_dir(models_dir).is_ok_and(|mut entries| entries.next().is_some())
784}
785
786async fn accept_loop(
787 listener: UnixListener,
788 graph: Arc<GraphMemory>,
789 context: Arc<DaemonContext>,
790 log: Arc<DaemonLog>,
791) {
792 loop {
793 let poll = context.idle.poll_interval();
794 tokio::select! {
795 accepted = listener.accept() => match accepted {
796 Ok((stream, _)) => {
797 let graph = Arc::clone(&graph);
798 let context = Arc::clone(&context);
799 let log = Arc::clone(&log);
800 tokio::spawn(async move {
801 handle_connection(stream, graph, context, log).await;
802 });
803 }
804 Err(err) => log.log(&format!("accept error: {err}")),
805 },
806 () = context.shutdown.notified() => {
807 log.log("shutdown requested");
808 break;
809 }
810 () = tokio::time::sleep(poll) => {
811 if context.idle.is_idle_at(Instant::now()) {
812 log.log("idle timeout — exiting");
813 break;
814 }
815 }
816 }
817 }
818}
819
820async fn handle_connection(
821 stream: UnixStream,
822 graph: Arc<GraphMemory>,
823 context: Arc<DaemonContext>,
824 log: Arc<DaemonLog>,
825) {
826 let _activity = ActivityGuard::new(Arc::clone(&context));
827 if let Err(err) = authorize_peer(&stream, context.owner_uid) {
828 log.log(&format!("rejected connection: {err}"));
829 return;
830 }
831
832 let (reader, mut writer) = stream.into_split();
833 let mut lines = BufReader::new(reader.take(MAX_REQUEST_BYTES)).lines();
834
835 loop {
836 let line = match lines.next_line().await {
837 Ok(Some(line)) => line,
838 Ok(None) => {
839 if request_cap_reached(&mut lines) {
842 let response = Response::failure(
843 "bad_request",
844 format!("request exceeds the {MAX_REQUEST_BYTES}-byte limit"),
845 );
846 log.log("rejected an oversized request");
847 let _ = write_response(&mut writer, &response).await;
848 }
849 break;
850 }
851 Err(err) => {
852 log.log(&format!("read error: {err}"));
853 break;
854 }
855 };
856 recharge_request_cap(&mut lines);
857 if line.trim().is_empty() {
858 continue;
859 }
860
861 let (response, stop) = match serde_json::from_str::<Request>(&line) {
862 Ok(Request::Hello) => (
863 Response::success(
864 serde_json::to_value(context.info()).unwrap_or(serde_json::Value::Null),
865 ),
866 false,
867 ),
868 Ok(Request::Shutdown) => (
869 Response::success(serde_json::json!({ "stopping": true })),
870 true,
871 ),
872 Ok(request) => {
873 let started = Instant::now();
874 let response = dispatch_graph(&graph, &request).await;
875 log.log(&format!(
876 "{} {} in {}ms",
877 request.op_name(),
878 if response.ok { "ok" } else { "failed" },
879 started.elapsed().as_millis()
880 ));
881 (response, false)
882 }
883 Err(err) => (
884 Response::failure("bad_request", format!("malformed request: {err}")),
885 false,
886 ),
887 };
888
889 if let Err(err) = write_response(&mut writer, &response).await {
890 log.log(&format!("write error: {err}"));
891 break;
892 }
893 context.idle.touch_at(Instant::now());
894
895 if stop {
896 context.shutdown.notify_one();
900 break;
901 }
902 }
903}
904
905type RequestLines = tokio::io::Lines<BufReader<tokio::io::Take<tokio::net::unix::OwnedReadHalf>>>;
907
908fn request_cap_reached(lines: &mut RequestLines) -> bool {
910 lines.get_mut().get_mut().limit() == 0
911}
912
913fn recharge_request_cap(lines: &mut RequestLines) {
915 lines.get_mut().get_mut().set_limit(MAX_REQUEST_BYTES);
916}
917
918fn authorize_peer(stream: &UnixStream, owner_uid: u32) -> Result<(), RecallError> {
921 let peer = stream.peer_cred().map_err(|err| {
922 RecallError::Daemon(format!("cannot read socket peer credentials: {err}"))
923 })?;
924 check_peer_uid(peer.uid(), owner_uid)
925}
926
927async fn write_response(
928 writer: &mut tokio::net::unix::OwnedWriteHalf,
929 response: &Response,
930) -> Result<(), RecallError> {
931 let mut line = serde_json::to_vec(response)?;
932 line.push(b'\n');
933 writer.write_all(&line).await?;
934 writer.flush().await?;
935 Ok(())
936}
937
938fn staging_path(socket_path: &Path) -> PathBuf {
940 let mut path = socket_path.as_os_str().to_os_string();
941 path.push(".new");
942 PathBuf::from(path)
943}
944
945fn bind_socket(socket_path: &Path) -> Result<UnixListener, RecallError> {
952 use std::os::unix::fs::PermissionsExt;
953
954 if let Some(parent) = socket_path.parent() {
955 crate::serve_client::ensure_socket_dir(parent)?;
956 }
957 if std::os::unix::net::UnixStream::connect(socket_path).is_ok() {
958 return Err(RecallError::Daemon(format!(
959 "another daemon is already listening on {}",
960 socket_path.display()
961 )));
962 }
963
964 let staged = staging_path(socket_path);
965 unlink_socket(&staged)?;
966 let listener = UnixListener::bind(&staged).map_err(|err| {
967 RecallError::Daemon(format!("cannot listen on {}: {err}", staged.display()))
968 })?;
969 std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(PRIVATE_FILE_MODE)).map_err(
970 |err| {
971 RecallError::Daemon(format!(
972 "cannot restrict the daemon socket {}: {err}",
973 staged.display()
974 ))
975 },
976 )?;
977
978 unlink_socket(socket_path)?;
980 std::fs::rename(&staged, socket_path).map_err(|err| {
981 let _ = std::fs::remove_file(&staged);
982 RecallError::Daemon(format!(
983 "cannot publish the daemon socket at {}: {err}",
984 socket_path.display()
985 ))
986 })?;
987 Ok(listener)
988}
989
990fn write_pidfile(socket_path: &Path) -> Result<(), RecallError> {
991 use std::io::Write as _;
992
993 let contents = serde_json::json!({
994 "pid": std::process::id(),
995 "version": env!("CARGO_PKG_VERSION"),
996 "socket_path": socket_path.display().to_string(),
997 });
998 let path = pidfile_path(socket_path);
999 let _ = std::fs::remove_file(&path);
1000 let mut file = crate::serve_security::create_new_private_file().open(&path)?;
1001 writeln!(file, "{contents}")?;
1002 Ok(())
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007 use super::*;
1008
1009 const fn assert_shareable<T: Send + Sync>() {}
1010
1011 #[test]
1012 fn graph_memory_is_shareable_across_tasks() {
1013 assert_shareable::<GraphMemory>();
1015 }
1016
1017 #[test]
1018 fn request_wire_format_is_op_and_args() {
1019 let request = Request::Search(SearchArgs {
1020 query: "rust".into(),
1021 limit: 5,
1022 entity_type: None,
1023 keyword: None,
1024 });
1025 let json = serde_json::to_value(&request).unwrap();
1026 assert_eq!(json["op"], "search");
1027 assert_eq!(json["args"]["query"], "rust");
1028 assert_eq!(json["args"]["limit"], 5);
1029 }
1030
1031 #[test]
1032 fn control_requests_serialize_without_args() {
1033 assert_eq!(
1034 serde_json::to_string(&Request::Hello).unwrap(),
1035 r#"{"op":"hello"}"#
1036 );
1037 assert_eq!(
1038 serde_json::to_string(&Request::Shutdown).unwrap(),
1039 r#"{"op":"shutdown"}"#
1040 );
1041 }
1042
1043 #[test]
1044 fn request_round_trips_every_variant() {
1045 let requests = vec![
1046 Request::Hello,
1047 Request::Status,
1048 Request::Search(SearchArgs {
1049 query: "q".into(),
1050 limit: 3,
1051 entity_type: Some("tool".into()),
1052 keyword: Some("k".into()),
1053 }),
1054 Request::SearchEpisodes(SearchEpisodesArgs {
1055 query: "q".into(),
1056 limit: 2,
1057 }),
1058 Request::Query(QueryArgs {
1059 query: "q".into(),
1060 limit: 10,
1061 entity_type: None,
1062 keyword: None,
1063 depth: 2,
1064 episodes: true,
1065 }),
1066 Request::Traverse(TraverseArgs {
1067 entity: "Rust".into(),
1068 depth: 1,
1069 type_filter: None,
1070 }),
1071 Request::AddEntity(AddEntityArgs {
1072 name: "Rust".into(),
1073 entity_type: "tool".into(),
1074 abstract_text: "language".into(),
1075 overview: None,
1076 source: Some("test".into()),
1077 }),
1078 Request::Relate(RelateArgs {
1079 from: "D".into(),
1080 rel_type: "USES".into(),
1081 to: "Rust".into(),
1082 description: None,
1083 source: None,
1084 }),
1085 Request::IngestArchive(IngestArchiveArgs {
1086 content: "# log".into(),
1087 session_id: "s1".into(),
1088 log_number: Some(7),
1089 provenance: Some(Provenance::External),
1090 }),
1091 Request::SyncPipeline(SyncPipelineArgs {
1092 docs: PipelineDocuments {
1093 learning: "# learning".into(),
1094 ..PipelineDocuments::default()
1095 },
1096 }),
1097 Request::Feedback(FeedbackArgs {
1098 session_id: "s1".into(),
1099 outcome: OutcomeKind::Success,
1100 }),
1101 Request::Shutdown,
1102 ];
1103
1104 for request in requests {
1105 let line = serde_json::to_string(&request).unwrap();
1106 let parsed: Request = serde_json::from_str(&line).unwrap();
1107 assert_eq!(parsed, request, "round trip failed for {line}");
1108 }
1109 }
1110
1111 #[test]
1112 fn ingest_requests_without_provenance_still_parse() {
1113 let parsed: Request = serde_json::from_str(
1116 r##"{"op":"ingest_archive","args":{"content":"# log","session_id":"s1"}}"##,
1117 )
1118 .unwrap();
1119 assert_eq!(
1120 parsed,
1121 Request::IngestArchive(IngestArchiveArgs {
1122 content: "# log".into(),
1123 session_id: "s1".into(),
1124 log_number: None,
1125 provenance: None,
1126 })
1127 );
1128 }
1129
1130 #[test]
1131 fn feedback_is_a_hot_op_with_a_snake_case_outcome() {
1132 let request = Request::Feedback(FeedbackArgs {
1133 session_id: "conversation-042".into(),
1134 outcome: OutcomeKind::Failed,
1135 });
1136 let json = serde_json::to_value(&request).unwrap();
1137 assert_eq!(json["op"], "feedback");
1138 assert_eq!(json["args"]["session_id"], "conversation-042");
1139 assert_eq!(json["args"]["outcome"], "failed");
1140 assert_eq!(request.op_name(), "feedback");
1141 }
1142
1143 #[test]
1144 fn optional_args_may_be_omitted_on_the_wire() {
1145 let parsed: Request =
1146 serde_json::from_str(r#"{"op":"search","args":{"query":"q","limit":1}}"#).unwrap();
1147 assert_eq!(
1148 parsed,
1149 Request::Search(SearchArgs {
1150 query: "q".into(),
1151 limit: 1,
1152 entity_type: None,
1153 keyword: None,
1154 })
1155 );
1156 }
1157
1158 #[test]
1159 fn success_response_carries_data_only() {
1160 let response = Response::success(serde_json::json!({"n": 1}));
1161 let json = serde_json::to_value(&response).unwrap();
1162 assert_eq!(json["ok"], true);
1163 assert_eq!(json["data"]["n"], 1);
1164 assert!(json.get("error").is_none());
1165 assert_eq!(response.into_result().unwrap(), serde_json::json!({"n": 1}));
1166 }
1167
1168 #[test]
1169 fn failure_response_maps_to_named_remote_error() {
1170 let response = Response::from_graph_error(&GraphError::Locked("store busy".into()));
1171 assert_eq!(response.error.as_ref().unwrap().code, "locked");
1172 let err = response.into_result().unwrap_err();
1173 assert!(err.to_string().contains("store locked"));
1174 assert!(matches!(err, RecallError::Remote { .. }));
1175 }
1176
1177 #[test]
1178 fn failure_response_round_trips() {
1179 let response = Response::failure("bad_request", "malformed");
1180 let line = serde_json::to_string(&response).unwrap();
1181 let parsed: Response = serde_json::from_str(&line).unwrap();
1182 assert_eq!(parsed, response);
1183 assert!(!parsed.ok);
1184 }
1185
1186 #[test]
1187 fn error_codes_are_distinct_per_kind() {
1188 assert_eq!(error_code(&GraphError::Locked(String::new())), "locked");
1189 assert_eq!(
1190 error_code(&GraphError::NotFound(String::new())),
1191 "not_found"
1192 );
1193 assert_eq!(error_code(&GraphError::Embed(String::new())), "embedding");
1194 }
1195
1196 #[test]
1197 fn idle_timer_fires_after_timeout() {
1198 let start = Instant::now();
1199 let tracker = IdleTracker::new_at(Some(Duration::from_secs(60)), start);
1200
1201 assert!(!tracker.is_idle_at(start + Duration::from_secs(59)));
1202 assert!(tracker.is_idle_at(start + Duration::from_secs(60)));
1203 }
1204
1205 #[test]
1206 fn idle_timer_never_fires_while_a_connection_is_open() {
1207 let start = Instant::now();
1208 let tracker = IdleTracker::new_at(Some(Duration::from_secs(1)), start);
1209
1210 tracker.begin();
1211 assert!(!tracker.is_idle_at(start + Duration::from_secs(600)));
1212 tracker.end();
1213
1214 tracker.touch_at(start);
1215 assert!(tracker.is_idle_at(start + Duration::from_secs(600)));
1216 }
1217
1218 #[test]
1219 fn activity_resets_the_idle_timer() {
1220 let start = Instant::now();
1221 let tracker = IdleTracker::new_at(Some(Duration::from_secs(10)), start);
1222
1223 tracker.touch_at(start + Duration::from_secs(9));
1224 assert!(!tracker.is_idle_at(start + Duration::from_secs(18)));
1225 assert!(tracker.is_idle_at(start + Duration::from_secs(19)));
1226 }
1227
1228 #[test]
1229 fn idle_shutdown_disabled_without_timeout() {
1230 let start = Instant::now();
1231 let tracker = IdleTracker::new_at(None, start);
1232 assert!(!tracker.is_idle_at(start + Duration::from_secs(86_400)));
1233 assert_eq!(tracker.poll_interval(), MAX_IDLE_POLL);
1234 }
1235
1236 #[test]
1237 fn poll_interval_is_bounded() {
1238 let now = Instant::now();
1239 assert_eq!(
1240 IdleTracker::new_at(Some(Duration::from_millis(10)), now).poll_interval(),
1241 MIN_IDLE_POLL
1242 );
1243 assert_eq!(
1244 IdleTracker::new_at(Some(Duration::from_secs(3600)), now).poll_interval(),
1245 MAX_IDLE_POLL
1246 );
1247 assert_eq!(
1248 IdleTracker::new_at(Some(Duration::from_secs(100)), now).poll_interval(),
1249 Duration::from_secs(10)
1250 );
1251 }
1252
1253 #[test]
1254 fn only_repeatable_operations_are_retryable() {
1255 assert!(Request::Status.is_retryable());
1256 assert!(Request::Traverse(TraverseArgs {
1257 entity: "Rust".into(),
1258 depth: 1,
1259 type_filter: None,
1260 })
1261 .is_retryable());
1262
1263 assert!(!Request::IngestArchive(IngestArchiveArgs {
1265 content: "# log".into(),
1266 session_id: "s1".into(),
1267 log_number: Some(1),
1268 provenance: None,
1269 })
1270 .is_retryable());
1271 assert!(!Request::AddEntity(AddEntityArgs {
1272 name: "Rust".into(),
1273 entity_type: "tool".into(),
1274 abstract_text: "language".into(),
1275 overview: None,
1276 source: None,
1277 })
1278 .is_retryable());
1279 }
1280
1281 #[test]
1282 fn wire_limits_are_clamped_into_a_servable_range() {
1283 assert_eq!(clamp_limit(10), 10);
1284 assert_eq!(clamp_limit(MAX_LIMIT), MAX_LIMIT);
1285 assert_eq!(clamp_limit(usize::MAX), MAX_LIMIT);
1288 assert_eq!(clamp_limit(0), 1);
1289 }
1290
1291 #[test]
1292 fn wire_depths_are_clamped_into_a_servable_range() {
1293 assert_eq!(clamp_depth(2), 2);
1294 assert_eq!(clamp_depth(MAX_DEPTH), MAX_DEPTH);
1295 assert_eq!(clamp_depth(u32::MAX), MAX_DEPTH);
1296 assert_eq!(clamp_depth(0), 1);
1297 }
1298
1299 #[test]
1300 fn the_staged_socket_sits_beside_the_published_one() {
1301 let socket = Path::new("/run/recall-echo/abc.sock");
1302 let staged = staging_path(socket);
1303 assert_eq!(staged.parent(), socket.parent());
1304 assert_ne!(staged, socket);
1305 assert!(staged.as_os_str().len() - socket.as_os_str().len() <= 7);
1308 }
1309
1310 #[test]
1311 fn pidfile_sits_beside_the_socket() {
1312 assert_eq!(
1313 pidfile_path(Path::new("/run/recall-echo/abc.sock")),
1314 PathBuf::from("/run/recall-echo/abc.sock.pid")
1315 );
1316 }
1317
1318 #[test]
1319 fn serve_options_from_config_uses_defaults() {
1320 let dir = tempfile::tempdir().unwrap();
1321 std::fs::write(
1322 dir.path().join(".recall-echo.toml"),
1323 format!(
1324 "[serve]\nsocket_path = \"{}/graph.sock\"\n",
1325 dir.path().display()
1326 ),
1327 )
1328 .unwrap();
1329
1330 let options = ServeOptions::from_config(dir.path(), false).unwrap();
1331 assert_eq!(options.idle_timeout, Some(Duration::from_secs(3600)));
1332 assert!(!options.log_to_stderr);
1333 assert_eq!(options.socket_path, dir.path().join("graph.sock"));
1334 }
1335
1336 #[test]
1337 fn foreground_disables_idle_shutdown() {
1338 let dir = tempfile::tempdir().unwrap();
1339 std::fs::write(
1340 dir.path().join(".recall-echo.toml"),
1341 format!(
1342 "[serve]\nsocket_path = \"{}/graph.sock\"\nidle_timeout_secs = 30\n",
1343 dir.path().display()
1344 ),
1345 )
1346 .unwrap();
1347
1348 let options = ServeOptions::from_config(dir.path(), true).unwrap();
1349 assert_eq!(options.idle_timeout, None);
1350 assert!(options.log_to_stderr);
1351 }
1352
1353 #[test]
1354 fn zero_idle_timeout_disables_idle_shutdown() {
1355 let dir = tempfile::tempdir().unwrap();
1356 std::fs::write(
1357 dir.path().join(".recall-echo.toml"),
1358 format!(
1359 "[serve]\nsocket_path = \"{}/graph.sock\"\nidle_timeout_secs = 0\n",
1360 dir.path().display()
1361 ),
1362 )
1363 .unwrap();
1364
1365 assert_eq!(
1366 ServeOptions::from_config(dir.path(), false)
1367 .unwrap()
1368 .idle_timeout,
1369 None
1370 );
1371 }
1372}