1use 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
43const MAX_IDLE_POLL: Duration = Duration::from_secs(30);
45const MIN_IDLE_POLL: Duration = Duration::from_millis(100);
47
48const MAX_REQUEST_BYTES: u64 = 8 * 1024 * 1024;
52const MAX_LIMIT: usize = 1000;
55const MAX_DEPTH: u32 = 8;
58const STORE_RELEASE_TIMEOUT: Duration = Duration::from_secs(10);
61const STORE_RELEASE_POLL: Duration = Duration::from_millis(10);
63const WORKER_STOP_TIMEOUT: Duration = Duration::from_secs(5);
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71#[serde(tag = "op", content = "args", rename_all = "snake_case")]
72pub enum Request {
73 Hello,
75 Status,
77 Search(SearchArgs),
79 SearchEpisodes(SearchEpisodesArgs),
81 Query(QueryArgs),
83 Traverse(TraverseArgs),
85 AddEntity(AddEntityArgs),
87 Relate(RelateArgs),
89 IngestArchive(IngestArchiveArgs),
91 SyncPipeline(SyncPipelineArgs),
93 Feedback(FeedbackArgs),
95 Shutdown,
97}
98
99impl Request {
100 #[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 #[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 | Request::SyncPipeline(_)
136 | 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 #[serde(default)]
211 pub provenance: Option<Provenance>,
212}
213
214#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241pub struct ResponseError {
242 pub code: String,
243 pub message: String,
244}
245
246impl Response {
247 #[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 #[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 #[must_use]
272 pub fn from_graph_error(err: &GraphError) -> Self {
273 Self::failure(error_code(err), err.to_string())
274 }
275
276 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
293fn 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#[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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
327#[serde(default)]
328pub struct ExtractionStatus {
329 pub enabled: bool,
331 pub disabled_reason: Option<String>,
333 pub runs: u64,
335 pub archives: u64,
337 pub last_run_secs_ago: Option<u64>,
339 pub last_run_ms: Option<u64>,
341 pub last_error: Option<String>,
343}
344
345pub 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
365fn clamp_limit(limit: usize) -> usize {
367 limit.clamp(1, MAX_LIMIT)
368}
369
370fn clamp_depth(depth: u32) -> u32 {
372 depth.clamp(1, MAX_DEPTH)
373}
374
375async 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 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#[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 #[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 pub fn begin(&self) {
517 self.active.fetch_add(1, Ordering::SeqCst);
518 self.touch_at(Instant::now());
519 }
520
521 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 pub fn begin_background(&self) {
531 self.background.fetch_add(1, Ordering::SeqCst);
532 }
533
534 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 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 #[must_use]
551 pub fn has_connections(&self) -> bool {
552 self.active.load(Ordering::SeqCst) > 0
553 }
554
555 #[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 #[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 #[must_use]
584 pub fn timeout(&self) -> Option<Duration> {
585 self.timeout
586 }
587
588 #[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
598struct 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
614pub 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#[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 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 pub async fn wait(&self) {
666 loop {
667 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 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 pub async fn sleep_until_stopped(&self, duration: Duration) -> bool {
690 self.guard(tokio::time::sleep(duration)).await.is_none()
691 }
692}
693
694#[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 pub fn enable(&self) {
730 self.with(|progress| {
731 progress.enabled = true;
732 progress.disabled_reason = None;
733 });
734 }
735
736 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 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 pub fn record_error(&self, error: impl Into<String>) {
756 self.with(|progress| progress.last_error = Some(error.into()));
757 }
758
759 #[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
776pub 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#[derive(Debug, Clone)]
824pub struct ServeOptions {
825 pub memory_dir: PathBuf,
827 pub socket_path: PathBuf,
829 pub idle_timeout: Option<Duration>,
831 pub log_to_stderr: bool,
833}
834
835impl ServeOptions {
836 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
855struct DaemonContext {
857 started: Instant,
858 memory_dir: PathBuf,
859 socket_path: PathBuf,
860 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#[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
888pub 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 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 context.shutdown.trigger();
950 stop_background_extraction(worker, &log).await;
951 stop_background_capture(capture, &log).await;
952
953 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
965async 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
990async 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
1012fn 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#[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
1069fn 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
1089async 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
1103async 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 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 context.shutdown.trigger();
1239 break;
1240 }
1241 }
1242}
1243
1244type RequestLines = tokio::io::Lines<BufReader<tokio::io::Take<tokio::net::unix::OwnedReadHalf>>>;
1246
1247fn request_cap_reached(lines: &mut RequestLines) -> bool {
1249 lines.get_mut().get_mut().limit() == 0
1250}
1251
1252fn recharge_request_cap(lines: &mut RequestLines) {
1254 lines.get_mut().get_mut().set_limit(MAX_REQUEST_BYTES);
1255}
1256
1257fn 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
1277fn 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
1284fn 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 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 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 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 #[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 assert!(tracker.is_quiet_at(later, Duration::from_secs(60)));
1582
1583 drop(guard);
1584 assert!(tracker.is_idle_at(later));
1585 }
1586
1587 #[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 #[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 assert_eq!(status.archives, 3);
1662 }
1663
1664 #[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 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 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 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}