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::correct::{CorrectTarget, Correction};
34use crate::graph::error::GraphError;
35use crate::graph::types::{
36 EntityType, NewEntity, NewRelationship, PipelineDocuments, QueryOptions, SearchOptions,
37};
38use crate::graph::utility::OutcomeKind;
39use crate::graph::{GraphMemory, IngestContext, Provenance};
40use crate::serve_security::{
41 append_private_file, check_peer_uid, current_uid, unlink_socket, PRIVATE_FILE_MODE,
42};
43
44const MAX_IDLE_POLL: Duration = Duration::from_secs(30);
46const MIN_IDLE_POLL: Duration = Duration::from_millis(100);
48
49const MAX_REQUEST_BYTES: u64 = 8 * 1024 * 1024;
53const MAX_LIMIT: usize = 1000;
56const MAX_DEPTH: u32 = 8;
59const DEFAULT_PER_TYPE: usize = 3;
61const MAX_PER_TYPE: usize = 20;
64const STORE_RELEASE_TIMEOUT: Duration = Duration::from_secs(10);
67const STORE_RELEASE_POLL: Duration = Duration::from_millis(10);
69const WORKER_STOP_TIMEOUT: Duration = Duration::from_secs(5);
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(tag = "op", content = "args", rename_all = "snake_case")]
78pub enum Request {
79 Hello,
81 Status,
83 Search(SearchArgs),
85 SearchEpisodes(SearchEpisodesArgs),
87 Query(QueryArgs),
89 Traverse(TraverseArgs),
91 AddEntity(AddEntityArgs),
93 Relate(RelateArgs),
95 IngestArchive(IngestArchiveArgs),
97 SyncPipeline(SyncPipelineArgs),
99 Feedback(FeedbackArgs),
101 Correct(CorrectArgs),
103 Overview(OverviewArgs),
105 About(AboutArgs),
107 Shutdown,
109}
110
111impl Request {
112 #[must_use]
114 pub fn op_name(&self) -> &'static str {
115 match self {
116 Request::Hello => "hello",
117 Request::Status => "status",
118 Request::Search(_) => "search",
119 Request::SearchEpisodes(_) => "search_episodes",
120 Request::Query(_) => "query",
121 Request::Traverse(_) => "traverse",
122 Request::AddEntity(_) => "add_entity",
123 Request::Relate(_) => "relate",
124 Request::IngestArchive(_) => "ingest_archive",
125 Request::SyncPipeline(_) => "sync_pipeline",
126 Request::Feedback(_) => "feedback",
127 Request::Correct(_) => "correct",
128 Request::Overview(_) => "overview",
129 Request::About(_) => "about",
130 Request::Shutdown => "shutdown",
131 }
132 }
133
134 #[must_use]
141 pub fn is_retryable(&self) -> bool {
142 match self {
143 Request::Hello
144 | Request::Status
145 | Request::Search(_)
146 | Request::SearchEpisodes(_)
147 | Request::Query(_)
148 | Request::Traverse(_)
149 | Request::SyncPipeline(_)
151 | Request::Feedback(_)
153 | Request::Overview(_)
154 | Request::About(_)
155 | Request::Shutdown => true,
156 Request::AddEntity(_)
159 | Request::Relate(_)
160 | Request::IngestArchive(_)
161 | Request::Correct(_) => false,
162 }
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub struct SearchArgs {
168 pub query: String,
169 pub limit: usize,
170 #[serde(default)]
171 pub entity_type: Option<String>,
172 #[serde(default)]
173 pub keyword: Option<String>,
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct SearchEpisodesArgs {
178 pub query: String,
179 pub limit: usize,
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183pub struct QueryArgs {
184 pub query: String,
185 pub limit: usize,
186 #[serde(default)]
187 pub entity_type: Option<String>,
188 #[serde(default)]
189 pub keyword: Option<String>,
190 pub depth: u32,
191 pub episodes: bool,
192}
193
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct TraverseArgs {
196 pub entity: String,
197 pub depth: u32,
198 #[serde(default)]
199 pub type_filter: Option<String>,
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203pub struct AddEntityArgs {
204 pub name: String,
205 pub entity_type: String,
206 pub abstract_text: String,
207 #[serde(default)]
208 pub overview: Option<String>,
209 #[serde(default)]
210 pub source: Option<String>,
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214pub struct RelateArgs {
215 pub from: String,
216 pub rel_type: String,
217 pub to: String,
218 #[serde(default)]
219 pub description: Option<String>,
220 #[serde(default)]
221 pub source: Option<String>,
222}
223
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
225pub struct IngestArchiveArgs {
226 pub content: String,
227 pub session_id: String,
228 #[serde(default)]
229 pub log_number: Option<u32>,
230 #[serde(default)]
233 pub provenance: Option<Provenance>,
234}
235
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
239pub struct SyncPipelineArgs {
240 pub docs: PipelineDocuments,
241}
242
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
244pub struct FeedbackArgs {
245 pub session_id: String,
246 pub outcome: OutcomeKind,
247}
248
249#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
251pub struct CorrectArgs {
252 pub target: CorrectTarget,
253 pub correction: Correction,
254}
255
256#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259pub struct OverviewArgs {
260 #[serde(default)]
261 pub per_type: usize,
262}
263
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265pub struct AboutArgs {
266 pub topic: String,
267 #[serde(default)]
268 pub limit: usize,
269}
270
271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274pub struct Response {
275 pub ok: bool,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub data: Option<serde_json::Value>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub error: Option<ResponseError>,
280}
281
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285pub struct ResponseError {
286 pub code: String,
287 pub message: String,
288}
289
290impl Response {
291 #[must_use]
293 pub fn success(data: serde_json::Value) -> Self {
294 Self {
295 ok: true,
296 data: Some(data),
297 error: None,
298 }
299 }
300
301 #[must_use]
303 pub fn failure(code: impl Into<String>, message: impl Into<String>) -> Self {
304 Self {
305 ok: false,
306 data: None,
307 error: Some(ResponseError {
308 code: code.into(),
309 message: message.into(),
310 }),
311 }
312 }
313
314 #[must_use]
316 pub fn from_graph_error(err: &GraphError) -> Self {
317 Self::failure(error_code(err), err.to_string())
318 }
319
320 pub fn into_result(self) -> Result<serde_json::Value, RecallError> {
323 if self.ok {
324 return Ok(self.data.unwrap_or(serde_json::Value::Null));
325 }
326 let error = self.error.unwrap_or(ResponseError {
327 code: "unknown".into(),
328 message: "daemon reported failure without a message".into(),
329 });
330 Err(RecallError::Remote {
331 code: error.code,
332 message: error.message,
333 })
334 }
335}
336
337fn error_code(err: &GraphError) -> &'static str {
339 match err {
340 GraphError::Db(_) => "db",
341 GraphError::Locked(_) => "locked",
342 GraphError::Embed(_) => "embedding",
343 GraphError::NotFound(_) => "not_found",
344 GraphError::Extraction(_) => "extraction",
345 GraphError::Dedup(_) => "dedup",
346 GraphError::Llm(_) => "llm",
347 GraphError::Parse(_) => "parse",
348 GraphError::Io(_) => "io",
349 GraphError::Json(_) => "json",
350 GraphError::ImmutableMerge(_) => "immutable_merge",
351 }
352}
353
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
359pub struct DaemonInfo {
360 pub version: String,
361 pub pid: u32,
362 pub memory_dir: String,
363 pub socket_path: String,
364 pub uptime_secs: u64,
365 #[serde(default)]
366 pub extraction: ExtractionStatus,
367}
368
369#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
371#[serde(default)]
372pub struct ExtractionStatus {
373 pub enabled: bool,
375 pub disabled_reason: Option<String>,
377 pub runs: u64,
379 pub archives: u64,
381 pub last_run_secs_ago: Option<u64>,
383 pub last_run_ms: Option<u64>,
385 pub last_error: Option<String>,
387}
388
389pub async fn dispatch_graph(graph: &GraphMemory, request: &Request) -> Response {
396 match execute_graph(graph, request).await {
397 Ok(Some(data)) => Response::success(data),
398 Ok(None) => Response::failure(
399 "unsupported",
400 format!(
401 "`{}` is a control operation, not a graph operation",
402 request.op_name()
403 ),
404 ),
405 Err(err) => Response::from_graph_error(&err),
406 }
407}
408
409fn clamp_limit(limit: usize) -> usize {
411 limit.clamp(1, MAX_LIMIT)
412}
413
414fn clamp_depth(depth: u32) -> u32 {
416 depth.clamp(1, MAX_DEPTH)
417}
418
419fn clamp_per_type(per_type: usize) -> usize {
422 if per_type == 0 {
423 return DEFAULT_PER_TYPE;
424 }
425 per_type.clamp(1, MAX_PER_TYPE)
426}
427
428async fn execute_graph(
430 graph: &GraphMemory,
431 request: &Request,
432) -> Result<Option<serde_json::Value>, GraphError> {
433 let data = match request {
434 Request::Hello | Request::Shutdown => return Ok(None),
435 Request::Status => serde_json::to_value(graph.stats().await?)?,
436 Request::Search(args) => {
437 let options = SearchOptions {
438 limit: clamp_limit(args.limit),
439 entity_type: args.entity_type.clone(),
440 keyword: args.keyword.clone(),
441 };
442 serde_json::to_value(graph.search_with_options(&args.query, &options).await?)?
443 }
444 Request::SearchEpisodes(args) => {
445 let mut episodes = graph
446 .search_episodes(&args.query, clamp_limit(args.limit))
447 .await?;
448 for result in &mut episodes {
449 result.episode.embedding = None;
450 }
451 serde_json::to_value(episodes)?
452 }
453 Request::Query(args) => {
454 let options = QueryOptions {
455 limit: clamp_limit(args.limit),
456 entity_type: args.entity_type.clone(),
457 keyword: args.keyword.clone(),
458 graph_depth: clamp_depth(args.depth),
459 include_episodes: args.episodes,
460 };
461 let mut result = graph.query(&args.query, &options).await?;
462 for episode in &mut result.episodes {
463 episode.episode.embedding = None;
464 }
465 serde_json::to_value(result)?
466 }
467 Request::Traverse(args) => serde_json::to_value(
468 graph
469 .traverse_filtered(
470 &args.entity,
471 clamp_depth(args.depth),
472 args.type_filter.as_deref(),
473 )
474 .await?,
475 )?,
476 Request::AddEntity(args) => {
477 let entity_type: EntityType = args
478 .entity_type
479 .parse()
480 .map_err(|e: String| GraphError::Parse(e))?;
481 let mut entity = graph
482 .add_entity(NewEntity {
483 name: args.name.clone(),
484 entity_type,
485 abstract_text: args.abstract_text.clone(),
486 overview: args.overview.clone(),
487 content: None,
488 attributes: None,
489 source: args.source.clone(),
490 })
491 .await?;
492 entity.embedding = None;
494 serde_json::to_value(entity)?
495 }
496 Request::Relate(args) => {
497 let relationship = graph
498 .add_relationship(NewRelationship {
499 from_entity: args.from.clone(),
500 to_entity: args.to.clone(),
501 rel_type: args.rel_type.clone(),
502 description: args.description.clone(),
503 confidence: None,
504 source: args.source.clone(),
505 })
506 .await?;
507 serde_json::to_value(relationship)?
508 }
509 Request::IngestArchive(args) => {
510 let context = IngestContext::new(args.session_id.clone(), args.log_number)
511 .with_override(args.provenance);
512 let report = graph.ingest_archive(&args.content, &context, None).await?;
513 serde_json::to_value(report)?
514 }
515 Request::SyncPipeline(args) => {
516 serde_json::to_value(graph.sync_pipeline(&args.docs).await?)?
517 }
518 Request::Feedback(args) => serde_json::to_value(
519 graph
520 .record_session_outcome(&args.session_id, args.outcome)
521 .await?,
522 )?,
523 Request::Correct(args) => {
524 serde_json::to_value(graph.correct(&args.target, args.correction).await?)?
525 }
526 Request::Overview(args) => {
527 serde_json::to_value(graph.overview(clamp_per_type(args.per_type)).await?)?
528 }
529 Request::About(args) => {
530 serde_json::to_value(graph.about(&args.topic, clamp_limit(args.limit)).await?)?
531 }
532 };
533 Ok(Some(data))
534}
535
536#[derive(Debug)]
553pub struct IdleTracker {
554 timeout: Option<Duration>,
555 active: AtomicUsize,
556 background: AtomicUsize,
557 last_activity: Mutex<Instant>,
558}
559
560impl IdleTracker {
561 #[must_use]
562 pub fn new(timeout: Option<Duration>) -> Self {
563 Self::new_at(timeout, Instant::now())
564 }
565
566 #[must_use]
568 pub fn new_at(timeout: Option<Duration>, start: Instant) -> Self {
569 Self {
570 timeout,
571 active: AtomicUsize::new(0),
572 background: AtomicUsize::new(0),
573 last_activity: Mutex::new(start),
574 }
575 }
576
577 pub fn begin(&self) {
579 self.active.fetch_add(1, Ordering::SeqCst);
580 self.touch_at(Instant::now());
581 }
582
583 pub fn end(&self) {
585 let previous = self.active.fetch_sub(1, Ordering::SeqCst);
586 debug_assert!(previous > 0, "IdleTracker::end without begin");
587 self.touch_at(Instant::now());
588 }
589
590 pub fn begin_background(&self) {
593 self.background.fetch_add(1, Ordering::SeqCst);
594 }
595
596 pub fn end_background(&self) {
598 let previous = self.background.fetch_sub(1, Ordering::SeqCst);
599 debug_assert!(previous > 0, "IdleTracker::end_background without begin");
600 }
601
602 pub fn touch_at(&self, now: Instant) {
604 let mut last = self
605 .last_activity
606 .lock()
607 .unwrap_or_else(|poisoned| poisoned.into_inner());
608 *last = now;
609 }
610
611 #[must_use]
613 pub fn has_connections(&self) -> bool {
614 self.active.load(Ordering::SeqCst) > 0
615 }
616
617 #[must_use]
620 pub fn is_quiet_at(&self, now: Instant, quiet: Duration) -> bool {
621 if self.has_connections() {
622 return false;
623 }
624 let last = *self
625 .last_activity
626 .lock()
627 .unwrap_or_else(|poisoned| poisoned.into_inner());
628 now.saturating_duration_since(last) >= quiet
629 }
630
631 #[must_use]
634 pub fn is_idle_at(&self, now: Instant) -> bool {
635 let Some(timeout) = self.timeout else {
636 return false;
637 };
638 if self.background.load(Ordering::SeqCst) > 0 {
639 return false;
640 }
641 self.is_quiet_at(now, timeout)
642 }
643
644 #[must_use]
646 pub fn timeout(&self) -> Option<Duration> {
647 self.timeout
648 }
649
650 #[must_use]
652 pub fn poll_interval(&self) -> Duration {
653 match self.timeout {
654 None => MAX_IDLE_POLL,
655 Some(timeout) => (timeout / 10).clamp(MIN_IDLE_POLL, MAX_IDLE_POLL),
656 }
657 }
658}
659
660struct ActivityGuard(Arc<DaemonContext>);
662
663impl ActivityGuard {
664 fn new(context: Arc<DaemonContext>) -> Self {
665 context.idle.begin();
666 Self(context)
667 }
668}
669
670impl Drop for ActivityGuard {
671 fn drop(&mut self) {
672 self.0.idle.end();
673 }
674}
675
676pub struct BackgroundGuard(Arc<IdleTracker>);
681
682impl BackgroundGuard {
683 #[must_use]
684 pub fn new(idle: Arc<IdleTracker>) -> Self {
685 idle.begin_background();
686 Self(idle)
687 }
688}
689
690impl Drop for BackgroundGuard {
691 fn drop(&mut self) {
692 self.0.end_background();
693 }
694}
695
696#[derive(Debug, Default)]
704pub struct ShutdownSignal {
705 triggered: std::sync::atomic::AtomicBool,
706 notify: Notify,
707}
708
709impl ShutdownSignal {
710 #[must_use]
711 pub fn new() -> Self {
712 Self::default()
713 }
714
715 pub fn trigger(&self) {
717 self.triggered.store(true, Ordering::SeqCst);
718 self.notify.notify_waiters();
719 }
720
721 #[must_use]
722 pub fn is_triggered(&self) -> bool {
723 self.triggered.load(Ordering::SeqCst)
724 }
725
726 pub async fn wait(&self) {
728 loop {
729 let notified = self.notify.notified();
732 tokio::pin!(notified);
733 notified.as_mut().enable();
734 if self.is_triggered() {
735 return;
736 }
737 notified.await;
738 }
739 }
740
741 pub async fn guard<F: std::future::Future>(&self, future: F) -> Option<F::Output> {
743 tokio::select! {
744 biased;
745 () = self.wait() => None,
746 output = future => Some(output),
747 }
748 }
749
750 pub async fn sleep_until_stopped(&self, duration: Duration) -> bool {
752 self.guard(tokio::time::sleep(duration)).await.is_none()
753 }
754}
755
756#[derive(Debug, Default)]
761pub struct ExtractionState {
762 inner: Mutex<ExtractionProgress>,
763}
764
765#[derive(Debug, Default)]
766struct ExtractionProgress {
767 enabled: bool,
768 disabled_reason: Option<String>,
769 runs: u64,
770 archives: u64,
771 last_run: Option<Instant>,
772 last_run_ms: Option<u64>,
773 last_error: Option<String>,
774}
775
776impl ExtractionState {
777 #[must_use]
778 pub fn shared() -> Arc<Self> {
779 Arc::new(Self::default())
780 }
781
782 fn with<T>(&self, apply: impl FnOnce(&mut ExtractionProgress) -> T) -> T {
783 let mut progress = self
784 .inner
785 .lock()
786 .unwrap_or_else(|poisoned| poisoned.into_inner());
787 apply(&mut progress)
788 }
789
790 pub fn enable(&self) {
792 self.with(|progress| {
793 progress.enabled = true;
794 progress.disabled_reason = None;
795 });
796 }
797
798 pub fn disable(&self, reason: impl Into<String>) {
800 self.with(|progress| {
801 progress.enabled = false;
802 progress.disabled_reason = Some(reason.into());
803 });
804 }
805
806 pub fn record_batch(&self, archives: u64, elapsed: Duration, finished_at: Instant) {
808 self.with(|progress| {
809 progress.runs += 1;
810 progress.archives += archives;
811 progress.last_run = Some(finished_at);
812 progress.last_run_ms = Some(elapsed.as_millis() as u64);
813 });
814 }
815
816 pub fn record_error(&self, error: impl Into<String>) {
818 self.with(|progress| progress.last_error = Some(error.into()));
819 }
820
821 #[must_use]
823 pub fn snapshot(&self, now: Instant) -> ExtractionStatus {
824 self.with(|progress| ExtractionStatus {
825 enabled: progress.enabled,
826 disabled_reason: progress.disabled_reason.clone(),
827 runs: progress.runs,
828 archives: progress.archives,
829 last_run_secs_ago: progress
830 .last_run
831 .map(|at| now.saturating_duration_since(at).as_secs()),
832 last_run_ms: progress.last_run_ms,
833 last_error: progress.last_error.clone(),
834 })
835 }
836}
837
838pub struct DaemonLog {
844 file: Mutex<Option<std::fs::File>>,
845 echo_stderr: bool,
846}
847
848impl DaemonLog {
849 #[must_use]
850 pub fn open(path: &Path, echo_stderr: bool) -> Self {
851 let file = append_private_file().open(path).ok();
852 Self {
853 file: Mutex::new(file),
854 echo_stderr,
855 }
856 }
857
858 pub fn log(&self, message: &str) {
859 use std::io::Write as _;
860 let line = format!(
861 "[{}] pid={} {message}\n",
862 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"),
863 std::process::id(),
864 );
865 let mut guard = self
866 .file
867 .lock()
868 .unwrap_or_else(|poisoned| poisoned.into_inner());
869 match guard.as_mut() {
870 Some(file) => {
871 let _ = file.write_all(line.as_bytes());
872 let _ = file.flush();
873 if self.echo_stderr {
874 eprint!("{line}");
875 }
876 }
877 None => eprint!("{line}"),
878 }
879 }
880}
881
882#[derive(Debug, Clone)]
886pub struct ServeOptions {
887 pub memory_dir: PathBuf,
889 pub socket_path: PathBuf,
891 pub idle_timeout: Option<Duration>,
893 pub log_to_stderr: bool,
895}
896
897impl ServeOptions {
898 pub fn from_config(memory_dir: &Path, foreground: bool) -> Result<Self, RecallError> {
903 let config = crate::config::load_from_dir(memory_dir);
904 let idle_timeout = match (foreground, config.serve.idle_timeout_secs) {
905 (true, _) | (_, 0) => None,
906 (false, secs) => Some(Duration::from_secs(secs)),
907 };
908 Ok(Self {
909 memory_dir: memory_dir.to_path_buf(),
910 socket_path: crate::serve_client::socket_path(memory_dir)?,
911 idle_timeout,
912 log_to_stderr: foreground,
913 })
914 }
915}
916
917struct DaemonContext {
919 started: Instant,
920 memory_dir: PathBuf,
921 socket_path: PathBuf,
922 owner_uid: u32,
924 idle: Arc<IdleTracker>,
925 shutdown: Arc<ShutdownSignal>,
926 extraction: Arc<ExtractionState>,
927}
928
929impl DaemonContext {
930 fn info(&self) -> DaemonInfo {
931 DaemonInfo {
932 version: env!("CARGO_PKG_VERSION").to_string(),
933 pid: std::process::id(),
934 memory_dir: self.memory_dir.display().to_string(),
935 socket_path: self.socket_path.display().to_string(),
936 uptime_secs: self.started.elapsed().as_secs(),
937 extraction: self.extraction.snapshot(Instant::now()),
938 }
939 }
940}
941
942#[must_use]
944pub fn pidfile_path(socket_path: &Path) -> PathBuf {
945 let mut path = socket_path.as_os_str().to_os_string();
946 path.push(".pid");
947 PathBuf::from(path)
948}
949
950pub async fn run(options: ServeOptions) -> Result<(), RecallError> {
952 let graph_dir = options.memory_dir.join("graph");
953 std::fs::create_dir_all(&graph_dir)?;
954
955 let log = Arc::new(DaemonLog::open(
956 &graph_dir.join("daemon.log"),
957 options.log_to_stderr,
958 ));
959 log.log(&format!(
960 "starting v{} for {} on {}",
961 env!("CARGO_PKG_VERSION"),
962 options.memory_dir.display(),
963 options.socket_path.display()
964 ));
965
966 if crate::serve_client::graph_mode(&options.memory_dir) == "server" {
967 log.log("warning: [graph] mode = \"server\" — clients bypass the daemon");
968 }
969
970 let owner_uid = current_uid()?;
971
972 let graph = open_store(&graph_dir, &options.socket_path, &log).await?;
975
976 let listener = match bind_socket(&options.socket_path) {
977 Ok(listener) => listener,
978 Err(err) => {
979 log.log(&format!("failed to bind socket: {err}"));
980 return Err(err);
981 }
982 };
983 write_pidfile(&options.socket_path)?;
984
985 let extraction = ExtractionState::shared();
986 let context = Arc::new(DaemonContext {
987 started: Instant::now(),
988 memory_dir: options.memory_dir.clone(),
989 socket_path: options.socket_path.clone(),
990 owner_uid,
991 idle: Arc::new(IdleTracker::new(options.idle_timeout)),
992 shutdown: Arc::new(ShutdownSignal::new()),
993 extraction: Arc::clone(&extraction),
994 });
995 warm_embedder(Arc::clone(&graph), Arc::clone(&log));
996 let worker = start_background_extraction(&options, &graph, &context, &log);
997 let capture = start_background_capture(&options, &graph, &context, &log);
998 log.log("ready");
999
1000 accept_loop(
1001 listener,
1002 Arc::clone(&graph),
1003 Arc::clone(&context),
1004 Arc::clone(&log),
1005 )
1006 .await;
1007
1008 context.shutdown.trigger();
1012 stop_background_extraction(worker, &log).await;
1013 stop_background_capture(capture, &log).await;
1014
1015 release_store(graph, &log).await;
1019 if let Err(err) = unlink_socket(&options.socket_path) {
1020 log.log(&format!("socket cleanup: {err}"));
1021 }
1022 let _ = std::fs::remove_file(pidfile_path(&options.socket_path));
1023 log.log("stopped");
1024 Ok(())
1025}
1026
1027async fn open_store(
1029 graph_dir: &Path,
1030 socket_path: &Path,
1031 log: &DaemonLog,
1032) -> Result<Arc<GraphMemory>, RecallError> {
1033 let deadline = Instant::now() + crate::serve_client::ADMIN_WAIT_TIMEOUT;
1034 loop {
1035 crate::serve_client::wait_for_admin_lock(socket_path, deadline).await?;
1036 match GraphMemory::open_embedded(graph_dir).await {
1037 Ok(graph) => return Ok(Arc::new(graph)),
1038 Err(GraphError::Locked(message))
1039 if Instant::now() < deadline
1040 && crate::serve_client::admin_lock_is_held(socket_path) =>
1041 {
1042 log.log(&format!("waiting for an admin operation: {message}"));
1043 }
1044 Err(err) => {
1045 log.log(&format!("failed to open graph store: {err}"));
1046 return Err(err.into());
1047 }
1048 }
1049 }
1050}
1051
1052async fn release_store(graph: Arc<GraphMemory>, log: &DaemonLog) {
1054 let deadline = Instant::now() + STORE_RELEASE_TIMEOUT;
1055 let mut graph = graph;
1056 loop {
1057 match Arc::try_unwrap(graph) {
1058 Ok(store) => {
1059 drop(store);
1060 return;
1061 }
1062 Err(shared) => {
1063 if Instant::now() >= deadline {
1064 log.log("gave up waiting for in-flight requests to release the store");
1065 return;
1066 }
1067 graph = shared;
1068 tokio::time::sleep(STORE_RELEASE_POLL).await;
1069 }
1070 }
1071 }
1072}
1073
1074fn warm_embedder(graph: Arc<GraphMemory>, log: Arc<DaemonLog>) {
1081 let models_dir = graph.path().join("models");
1082 if !has_cached_model(&models_dir) {
1083 return;
1084 }
1085 tokio::task::spawn_blocking(move || {
1086 let started = Instant::now();
1087 match graph.embedder() {
1088 Ok(_) => log.log(&format!(
1089 "embedder warm in {}ms",
1090 started.elapsed().as_millis()
1091 )),
1092 Err(err) => log.log(&format!("embedder warm-up failed: {err}")),
1093 }
1094 });
1095}
1096
1097#[cfg(feature = "llm")]
1101fn start_background_extraction(
1102 options: &ServeOptions,
1103 graph: &Arc<GraphMemory>,
1104 context: &Arc<DaemonContext>,
1105 log: &Arc<DaemonLog>,
1106) -> Option<tokio::task::JoinHandle<()>> {
1107 crate::serve_extract::spawn(crate::serve_extract::Setup {
1108 memory_dir: options.memory_dir.clone(),
1109 graph: Arc::clone(graph),
1110 idle: Arc::clone(&context.idle),
1111 shutdown: Arc::clone(&context.shutdown),
1112 state: Arc::clone(&context.extraction),
1113 log: Arc::clone(log),
1114 })
1115}
1116
1117#[cfg(not(feature = "llm"))]
1118fn start_background_extraction(
1119 _options: &ServeOptions,
1120 _graph: &Arc<GraphMemory>,
1121 context: &Arc<DaemonContext>,
1122 log: &Arc<DaemonLog>,
1123) -> Option<tokio::task::JoinHandle<()>> {
1124 context
1125 .extraction
1126 .disable("this binary was built without the `llm` feature");
1127 log.log("background extraction off: built without the `llm` feature");
1128 None
1129}
1130
1131fn start_background_capture(
1137 options: &ServeOptions,
1138 graph: &Arc<GraphMemory>,
1139 context: &Arc<DaemonContext>,
1140 log: &Arc<DaemonLog>,
1141) -> Option<tokio::task::JoinHandle<()>> {
1142 crate::serve_capture::spawn(crate::serve_capture::Setup {
1143 memory_dir: options.memory_dir.clone(),
1144 graph: Arc::clone(graph),
1145 idle: Arc::clone(&context.idle),
1146 shutdown: Arc::clone(&context.shutdown),
1147 log: Arc::clone(log),
1148 })
1149}
1150
1151async fn stop_background_capture(worker: Option<tokio::task::JoinHandle<()>>, log: &DaemonLog) {
1153 let Some(mut worker) = worker else {
1154 return;
1155 };
1156 if tokio::time::timeout(WORKER_STOP_TIMEOUT, &mut worker)
1157 .await
1158 .is_err()
1159 {
1160 worker.abort();
1161 log.log("background capture did not stop in time — abandoned mid-transcript");
1162 }
1163}
1164
1165async fn stop_background_extraction(worker: Option<tokio::task::JoinHandle<()>>, log: &DaemonLog) {
1171 let Some(mut worker) = worker else {
1172 return;
1173 };
1174 if tokio::time::timeout(WORKER_STOP_TIMEOUT, &mut worker)
1175 .await
1176 .is_err()
1177 {
1178 worker.abort();
1179 log.log("background extraction did not stop in time — abandoned mid-archive");
1180 }
1181}
1182
1183fn has_cached_model(models_dir: &Path) -> bool {
1184 std::fs::read_dir(models_dir).is_ok_and(|mut entries| entries.next().is_some())
1185}
1186
1187async fn accept_loop(
1188 listener: UnixListener,
1189 graph: Arc<GraphMemory>,
1190 context: Arc<DaemonContext>,
1191 log: Arc<DaemonLog>,
1192) {
1193 loop {
1194 let poll = context.idle.poll_interval();
1195 tokio::select! {
1196 accepted = listener.accept() => match accepted {
1197 Ok((stream, _)) => {
1198 let graph = Arc::clone(&graph);
1199 let context = Arc::clone(&context);
1200 let log = Arc::clone(&log);
1201 tokio::spawn(async move {
1202 handle_connection(stream, graph, context, log).await;
1203 });
1204 }
1205 Err(err) => log.log(&format!("accept error: {err}")),
1206 },
1207 () = context.shutdown.wait() => {
1208 log.log("shutdown requested");
1209 break;
1210 }
1211 () = tokio::time::sleep(poll) => {
1212 if context.idle.is_idle_at(Instant::now()) {
1213 log.log("idle timeout — exiting");
1214 break;
1215 }
1216 }
1217 }
1218 }
1219}
1220
1221async fn handle_connection(
1222 stream: UnixStream,
1223 graph: Arc<GraphMemory>,
1224 context: Arc<DaemonContext>,
1225 log: Arc<DaemonLog>,
1226) {
1227 let _activity = ActivityGuard::new(Arc::clone(&context));
1228 if let Err(err) = authorize_peer(&stream, context.owner_uid) {
1229 log.log(&format!("rejected connection: {err}"));
1230 return;
1231 }
1232
1233 let (reader, mut writer) = stream.into_split();
1234 let mut lines = BufReader::new(reader.take(MAX_REQUEST_BYTES)).lines();
1235
1236 loop {
1237 let line = match lines.next_line().await {
1238 Ok(Some(line)) => line,
1239 Ok(None) => {
1240 if request_cap_reached(&mut lines) {
1243 let response = Response::failure(
1244 "bad_request",
1245 format!("request exceeds the {MAX_REQUEST_BYTES}-byte limit"),
1246 );
1247 log.log("rejected an oversized request");
1248 let _ = write_response(&mut writer, &response).await;
1249 }
1250 break;
1251 }
1252 Err(err) => {
1253 log.log(&format!("read error: {err}"));
1254 break;
1255 }
1256 };
1257 recharge_request_cap(&mut lines);
1258 if line.trim().is_empty() {
1259 continue;
1260 }
1261
1262 let (response, stop) = match serde_json::from_str::<Request>(&line) {
1263 Ok(Request::Hello) => (
1264 Response::success(
1265 serde_json::to_value(context.info()).unwrap_or(serde_json::Value::Null),
1266 ),
1267 false,
1268 ),
1269 Ok(Request::Shutdown) => (
1270 Response::success(serde_json::json!({ "stopping": true })),
1271 true,
1272 ),
1273 Ok(request) => {
1274 let started = Instant::now();
1275 let response = dispatch_graph(&graph, &request).await;
1276 log.log(&format!(
1277 "{} {} in {}ms",
1278 request.op_name(),
1279 if response.ok { "ok" } else { "failed" },
1280 started.elapsed().as_millis()
1281 ));
1282 (response, false)
1283 }
1284 Err(err) => (
1285 Response::failure("bad_request", format!("malformed request: {err}")),
1286 false,
1287 ),
1288 };
1289
1290 if let Err(err) = write_response(&mut writer, &response).await {
1291 log.log(&format!("write error: {err}"));
1292 break;
1293 }
1294 context.idle.touch_at(Instant::now());
1295
1296 if stop {
1297 context.shutdown.trigger();
1301 break;
1302 }
1303 }
1304}
1305
1306type RequestLines = tokio::io::Lines<BufReader<tokio::io::Take<tokio::net::unix::OwnedReadHalf>>>;
1308
1309fn request_cap_reached(lines: &mut RequestLines) -> bool {
1311 lines.get_mut().get_mut().limit() == 0
1312}
1313
1314fn recharge_request_cap(lines: &mut RequestLines) {
1316 lines.get_mut().get_mut().set_limit(MAX_REQUEST_BYTES);
1317}
1318
1319fn authorize_peer(stream: &UnixStream, owner_uid: u32) -> Result<(), RecallError> {
1322 let peer = stream.peer_cred().map_err(|err| {
1323 RecallError::Daemon(format!("cannot read socket peer credentials: {err}"))
1324 })?;
1325 check_peer_uid(peer.uid(), owner_uid)
1326}
1327
1328async fn write_response(
1329 writer: &mut tokio::net::unix::OwnedWriteHalf,
1330 response: &Response,
1331) -> Result<(), RecallError> {
1332 let mut line = serde_json::to_vec(response)?;
1333 line.push(b'\n');
1334 writer.write_all(&line).await?;
1335 writer.flush().await?;
1336 Ok(())
1337}
1338
1339fn staging_path(socket_path: &Path) -> PathBuf {
1341 let mut path = socket_path.as_os_str().to_os_string();
1342 path.push(".new");
1343 PathBuf::from(path)
1344}
1345
1346fn bind_socket(socket_path: &Path) -> Result<UnixListener, RecallError> {
1353 use std::os::unix::fs::PermissionsExt;
1354
1355 if let Some(parent) = socket_path.parent() {
1356 crate::serve_client::ensure_socket_dir(parent)?;
1357 }
1358 if std::os::unix::net::UnixStream::connect(socket_path).is_ok() {
1359 return Err(RecallError::Daemon(format!(
1360 "another daemon is already listening on {}",
1361 socket_path.display()
1362 )));
1363 }
1364
1365 let staged = staging_path(socket_path);
1366 unlink_socket(&staged)?;
1367 let listener = UnixListener::bind(&staged).map_err(|err| {
1368 RecallError::Daemon(format!("cannot listen on {}: {err}", staged.display()))
1369 })?;
1370 std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(PRIVATE_FILE_MODE)).map_err(
1371 |err| {
1372 RecallError::Daemon(format!(
1373 "cannot restrict the daemon socket {}: {err}",
1374 staged.display()
1375 ))
1376 },
1377 )?;
1378
1379 unlink_socket(socket_path)?;
1381 std::fs::rename(&staged, socket_path).map_err(|err| {
1382 let _ = std::fs::remove_file(&staged);
1383 RecallError::Daemon(format!(
1384 "cannot publish the daemon socket at {}: {err}",
1385 socket_path.display()
1386 ))
1387 })?;
1388 Ok(listener)
1389}
1390
1391fn write_pidfile(socket_path: &Path) -> Result<(), RecallError> {
1392 use std::io::Write as _;
1393
1394 let contents = serde_json::json!({
1395 "pid": std::process::id(),
1396 "version": env!("CARGO_PKG_VERSION"),
1397 "socket_path": socket_path.display().to_string(),
1398 });
1399 let path = pidfile_path(socket_path);
1400 let _ = std::fs::remove_file(&path);
1401 let mut file = crate::serve_security::create_new_private_file().open(&path)?;
1402 writeln!(file, "{contents}")?;
1403 Ok(())
1404}
1405
1406#[cfg(test)]
1407mod tests {
1408 use super::*;
1409
1410 const fn assert_shareable<T: Send + Sync>() {}
1411
1412 #[test]
1413 fn graph_memory_is_shareable_across_tasks() {
1414 assert_shareable::<GraphMemory>();
1416 }
1417
1418 #[test]
1419 fn request_wire_format_is_op_and_args() {
1420 let request = Request::Search(SearchArgs {
1421 query: "rust".into(),
1422 limit: 5,
1423 entity_type: None,
1424 keyword: None,
1425 });
1426 let json = serde_json::to_value(&request).unwrap();
1427 assert_eq!(json["op"], "search");
1428 assert_eq!(json["args"]["query"], "rust");
1429 assert_eq!(json["args"]["limit"], 5);
1430 }
1431
1432 #[test]
1433 fn control_requests_serialize_without_args() {
1434 assert_eq!(
1435 serde_json::to_string(&Request::Hello).unwrap(),
1436 r#"{"op":"hello"}"#
1437 );
1438 assert_eq!(
1439 serde_json::to_string(&Request::Shutdown).unwrap(),
1440 r#"{"op":"shutdown"}"#
1441 );
1442 }
1443
1444 #[test]
1445 fn request_round_trips_every_variant() {
1446 let requests = vec![
1447 Request::Hello,
1448 Request::Status,
1449 Request::Search(SearchArgs {
1450 query: "q".into(),
1451 limit: 3,
1452 entity_type: Some("tool".into()),
1453 keyword: Some("k".into()),
1454 }),
1455 Request::SearchEpisodes(SearchEpisodesArgs {
1456 query: "q".into(),
1457 limit: 2,
1458 }),
1459 Request::Query(QueryArgs {
1460 query: "q".into(),
1461 limit: 10,
1462 entity_type: None,
1463 keyword: None,
1464 depth: 2,
1465 episodes: true,
1466 }),
1467 Request::Traverse(TraverseArgs {
1468 entity: "Rust".into(),
1469 depth: 1,
1470 type_filter: None,
1471 }),
1472 Request::AddEntity(AddEntityArgs {
1473 name: "Rust".into(),
1474 entity_type: "tool".into(),
1475 abstract_text: "language".into(),
1476 overview: None,
1477 source: Some("test".into()),
1478 }),
1479 Request::Relate(RelateArgs {
1480 from: "D".into(),
1481 rel_type: "USES".into(),
1482 to: "Rust".into(),
1483 description: None,
1484 source: None,
1485 }),
1486 Request::IngestArchive(IngestArchiveArgs {
1487 content: "# log".into(),
1488 session_id: "s1".into(),
1489 log_number: Some(7),
1490 provenance: Some(Provenance::External),
1491 }),
1492 Request::SyncPipeline(SyncPipelineArgs {
1493 docs: PipelineDocuments {
1494 learning: "# learning".into(),
1495 ..PipelineDocuments::default()
1496 },
1497 }),
1498 Request::Feedback(FeedbackArgs {
1499 session_id: "s1".into(),
1500 outcome: OutcomeKind::Success,
1501 }),
1502 Request::Correct(CorrectArgs {
1503 target: CorrectTarget::Edge {
1504 from: "D".into(),
1505 rel_type: "USES".into(),
1506 to: "Vim".into(),
1507 },
1508 correction: Correction::Wrong { all_edges: false },
1509 }),
1510 Request::Correct(CorrectArgs {
1511 target: CorrectTarget::Entity { name: "Vim".into() },
1512 correction: Correction::Forget { confirmed: true },
1513 }),
1514 Request::Overview(OverviewArgs { per_type: 3 }),
1515 Request::About(AboutArgs {
1516 topic: "rust".into(),
1517 limit: 5,
1518 }),
1519 Request::Shutdown,
1520 ];
1521
1522 for request in requests {
1523 let line = serde_json::to_string(&request).unwrap();
1524 let parsed: Request = serde_json::from_str(&line).unwrap();
1525 assert_eq!(parsed, request, "round trip failed for {line}");
1526 }
1527 }
1528
1529 #[test]
1530 fn ingest_requests_without_provenance_still_parse() {
1531 let parsed: Request = serde_json::from_str(
1534 r##"{"op":"ingest_archive","args":{"content":"# log","session_id":"s1"}}"##,
1535 )
1536 .unwrap();
1537 assert_eq!(
1538 parsed,
1539 Request::IngestArchive(IngestArchiveArgs {
1540 content: "# log".into(),
1541 session_id: "s1".into(),
1542 log_number: None,
1543 provenance: None,
1544 })
1545 );
1546 }
1547
1548 #[test]
1549 fn feedback_is_a_hot_op_with_a_snake_case_outcome() {
1550 let request = Request::Feedback(FeedbackArgs {
1551 session_id: "conversation-042".into(),
1552 outcome: OutcomeKind::Failed,
1553 });
1554 let json = serde_json::to_value(&request).unwrap();
1555 assert_eq!(json["op"], "feedback");
1556 assert_eq!(json["args"]["session_id"], "conversation-042");
1557 assert_eq!(json["args"]["outcome"], "failed");
1558 assert_eq!(request.op_name(), "feedback");
1559 }
1560
1561 #[test]
1562 fn optional_args_may_be_omitted_on_the_wire() {
1563 let parsed: Request =
1564 serde_json::from_str(r#"{"op":"search","args":{"query":"q","limit":1}}"#).unwrap();
1565 assert_eq!(
1566 parsed,
1567 Request::Search(SearchArgs {
1568 query: "q".into(),
1569 limit: 1,
1570 entity_type: None,
1571 keyword: None,
1572 })
1573 );
1574 }
1575
1576 #[test]
1577 fn success_response_carries_data_only() {
1578 let response = Response::success(serde_json::json!({"n": 1}));
1579 let json = serde_json::to_value(&response).unwrap();
1580 assert_eq!(json["ok"], true);
1581 assert_eq!(json["data"]["n"], 1);
1582 assert!(json.get("error").is_none());
1583 assert_eq!(response.into_result().unwrap(), serde_json::json!({"n": 1}));
1584 }
1585
1586 #[test]
1587 fn failure_response_maps_to_named_remote_error() {
1588 let response = Response::from_graph_error(&GraphError::Locked("store busy".into()));
1589 assert_eq!(response.error.as_ref().unwrap().code, "locked");
1590 let err = response.into_result().unwrap_err();
1591 assert!(err.to_string().contains("store locked"));
1592 assert!(matches!(err, RecallError::Remote { .. }));
1593 }
1594
1595 #[test]
1596 fn failure_response_round_trips() {
1597 let response = Response::failure("bad_request", "malformed");
1598 let line = serde_json::to_string(&response).unwrap();
1599 let parsed: Response = serde_json::from_str(&line).unwrap();
1600 assert_eq!(parsed, response);
1601 assert!(!parsed.ok);
1602 }
1603
1604 #[test]
1605 fn error_codes_are_distinct_per_kind() {
1606 assert_eq!(error_code(&GraphError::Locked(String::new())), "locked");
1607 assert_eq!(
1608 error_code(&GraphError::NotFound(String::new())),
1609 "not_found"
1610 );
1611 assert_eq!(error_code(&GraphError::Embed(String::new())), "embedding");
1612 }
1613
1614 #[test]
1615 fn idle_timer_fires_after_timeout() {
1616 let start = Instant::now();
1617 let tracker = IdleTracker::new_at(Some(Duration::from_secs(60)), start);
1618
1619 assert!(!tracker.is_idle_at(start + Duration::from_secs(59)));
1620 assert!(tracker.is_idle_at(start + Duration::from_secs(60)));
1621 }
1622
1623 #[test]
1624 fn idle_timer_never_fires_while_a_connection_is_open() {
1625 let start = Instant::now();
1626 let tracker = IdleTracker::new_at(Some(Duration::from_secs(1)), start);
1627
1628 tracker.begin();
1629 assert!(!tracker.is_idle_at(start + Duration::from_secs(600)));
1630 tracker.end();
1631
1632 tracker.touch_at(start);
1633 assert!(tracker.is_idle_at(start + Duration::from_secs(600)));
1634 }
1635
1636 #[test]
1637 fn activity_resets_the_idle_timer() {
1638 let start = Instant::now();
1639 let tracker = IdleTracker::new_at(Some(Duration::from_secs(10)), start);
1640
1641 tracker.touch_at(start + Duration::from_secs(9));
1642 assert!(!tracker.is_idle_at(start + Duration::from_secs(18)));
1643 assert!(tracker.is_idle_at(start + Duration::from_secs(19)));
1644 }
1645
1646 #[test]
1650 fn a_batch_in_flight_defers_idle_shutdown() {
1651 let start = Instant::now();
1652 let tracker = Arc::new(IdleTracker::new_at(Some(Duration::from_secs(60)), start));
1653 let later = start + Duration::from_secs(600);
1654 assert!(tracker.is_idle_at(later));
1655
1656 let guard = BackgroundGuard::new(Arc::clone(&tracker));
1657 assert!(!tracker.is_idle_at(later), "idle exit cut a batch in half");
1658 assert!(tracker.is_quiet_at(later, Duration::from_secs(60)));
1661
1662 drop(guard);
1663 assert!(tracker.is_idle_at(later));
1664 }
1665
1666 #[test]
1669 fn quiet_is_independent_of_the_idle_timeout() {
1670 let start = Instant::now();
1671 let tracker = IdleTracker::new_at(None, start);
1672 let later = start + Duration::from_secs(300);
1673
1674 assert!(!tracker.is_idle_at(later));
1675 assert!(tracker.is_quiet_at(later, Duration::from_secs(120)));
1676 }
1677
1678 #[test]
1679 fn an_open_connection_is_never_quiet() {
1680 let start = Instant::now();
1681 let tracker = IdleTracker::new_at(Some(Duration::from_secs(60)), start);
1682 let later = start + Duration::from_secs(600);
1683
1684 tracker.begin();
1685 assert!(tracker.has_connections());
1686 assert!(!tracker.is_quiet_at(later, Duration::from_secs(1)));
1687
1688 tracker.end();
1689 tracker.touch_at(start);
1690 assert!(tracker.is_quiet_at(later, Duration::from_secs(1)));
1691 }
1692
1693 #[tokio::test]
1697 async fn the_shutdown_signal_latches_for_late_waiters() {
1698 let signal = ShutdownSignal::new();
1699 assert!(!signal.is_triggered());
1700 signal.trigger();
1701 assert!(signal.is_triggered());
1702
1703 tokio::time::timeout(Duration::from_secs(5), signal.wait())
1704 .await
1705 .expect("a waiter that arrives after the trigger still wakes");
1706 assert!(signal.guard(std::future::pending::<()>()).await.is_none());
1707 assert!(signal.sleep_until_stopped(Duration::from_secs(600)).await);
1708 }
1709
1710 #[tokio::test]
1711 async fn the_shutdown_signal_lets_work_finish_when_it_is_not_triggered() {
1712 let signal = ShutdownSignal::new();
1713 assert_eq!(signal.guard(async { 7 }).await, Some(7));
1714 assert!(!signal.sleep_until_stopped(Duration::from_millis(1)).await);
1715 }
1716
1717 #[test]
1718 fn extraction_status_reports_progress_and_refusals() {
1719 let state = ExtractionState::shared();
1720 let now = Instant::now();
1721 assert!(!state.snapshot(now).enabled);
1722
1723 state.enable();
1724 state.record_batch(3, Duration::from_millis(250), now);
1725 let status = state.snapshot(now + Duration::from_secs(9));
1726 assert!(status.enabled);
1727 assert_eq!(status.runs, 1);
1728 assert_eq!(status.archives, 3);
1729 assert_eq!(status.last_run_secs_ago, Some(9));
1730 assert_eq!(status.last_run_ms, Some(250));
1731
1732 state.disable("no usable LLM provider");
1733 let status = state.snapshot(now);
1734 assert!(!status.enabled);
1735 assert_eq!(
1736 status.disabled_reason.as_deref(),
1737 Some("no usable LLM provider")
1738 );
1739 assert_eq!(status.archives, 3);
1741 }
1742
1743 #[test]
1745 fn daemon_info_without_extraction_still_parses() {
1746 let info: DaemonInfo = serde_json::from_str(
1747 r#"{"version":"3.0.0","pid":1,"memory_dir":"/m","socket_path":"/s","uptime_secs":5}"#,
1748 )
1749 .expect("older daemon info");
1750 assert_eq!(info.extraction, ExtractionStatus::default());
1751 assert!(!info.extraction.enabled);
1752 }
1753
1754 #[test]
1755 fn idle_shutdown_disabled_without_timeout() {
1756 let start = Instant::now();
1757 let tracker = IdleTracker::new_at(None, start);
1758 assert!(!tracker.is_idle_at(start + Duration::from_secs(86_400)));
1759 assert_eq!(tracker.poll_interval(), MAX_IDLE_POLL);
1760 }
1761
1762 #[test]
1763 fn poll_interval_is_bounded() {
1764 let now = Instant::now();
1765 assert_eq!(
1766 IdleTracker::new_at(Some(Duration::from_millis(10)), now).poll_interval(),
1767 MIN_IDLE_POLL
1768 );
1769 assert_eq!(
1770 IdleTracker::new_at(Some(Duration::from_secs(3600)), now).poll_interval(),
1771 MAX_IDLE_POLL
1772 );
1773 assert_eq!(
1774 IdleTracker::new_at(Some(Duration::from_secs(100)), now).poll_interval(),
1775 Duration::from_secs(10)
1776 );
1777 }
1778
1779 #[test]
1780 fn only_repeatable_operations_are_retryable() {
1781 assert!(Request::Status.is_retryable());
1782 assert!(Request::Traverse(TraverseArgs {
1783 entity: "Rust".into(),
1784 depth: 1,
1785 type_filter: None,
1786 })
1787 .is_retryable());
1788
1789 assert!(!Request::IngestArchive(IngestArchiveArgs {
1791 content: "# log".into(),
1792 session_id: "s1".into(),
1793 log_number: Some(1),
1794 provenance: None,
1795 })
1796 .is_retryable());
1797 assert!(!Request::AddEntity(AddEntityArgs {
1798 name: "Rust".into(),
1799 entity_type: "tool".into(),
1800 abstract_text: "language".into(),
1801 overview: None,
1802 source: None,
1803 })
1804 .is_retryable());
1805 }
1806
1807 #[test]
1808 fn wire_limits_are_clamped_into_a_servable_range() {
1809 assert_eq!(clamp_limit(10), 10);
1810 assert_eq!(clamp_limit(MAX_LIMIT), MAX_LIMIT);
1811 assert_eq!(clamp_limit(usize::MAX), MAX_LIMIT);
1814 assert_eq!(clamp_limit(0), 1);
1815 }
1816
1817 #[test]
1820 fn a_correction_is_never_replayed() {
1821 assert!(!Request::Correct(CorrectArgs {
1822 target: CorrectTarget::Entity { name: "Vim".into() },
1823 correction: Correction::Wrong { all_edges: false },
1824 })
1825 .is_retryable());
1826 assert!(Request::Overview(OverviewArgs { per_type: 0 }).is_retryable());
1827 assert!(Request::About(AboutArgs {
1828 topic: "rust".into(),
1829 limit: 0,
1830 })
1831 .is_retryable());
1832 }
1833
1834 #[test]
1836 fn inspection_args_may_be_omitted_on_the_wire() {
1837 assert_eq!(
1838 serde_json::from_str::<Request>(r#"{"op":"overview","args":{}}"#).unwrap(),
1839 Request::Overview(OverviewArgs { per_type: 0 })
1840 );
1841 assert_eq!(
1842 serde_json::from_str::<Request>(r#"{"op":"about","args":{"topic":"rust"}}"#).unwrap(),
1843 Request::About(AboutArgs {
1844 topic: "rust".into(),
1845 limit: 0,
1846 })
1847 );
1848 assert_eq!(
1849 serde_json::from_str::<Request>(
1850 r#"{"op":"correct","args":{"target":{"kind":"entity","name":"Vim"},
1851 "correction":{"kind":"wrong"}}}"#
1852 )
1853 .unwrap(),
1854 Request::Correct(CorrectArgs {
1855 target: CorrectTarget::Entity { name: "Vim".into() },
1856 correction: Correction::Wrong { all_edges: false },
1857 })
1858 );
1859 }
1860
1861 #[test]
1862 fn an_absent_per_type_takes_the_default() {
1863 assert_eq!(clamp_per_type(0), DEFAULT_PER_TYPE);
1864 assert_eq!(clamp_per_type(5), 5);
1865 assert_eq!(clamp_per_type(usize::MAX), MAX_PER_TYPE);
1866 }
1867
1868 #[test]
1869 fn wire_depths_are_clamped_into_a_servable_range() {
1870 assert_eq!(clamp_depth(2), 2);
1871 assert_eq!(clamp_depth(MAX_DEPTH), MAX_DEPTH);
1872 assert_eq!(clamp_depth(u32::MAX), MAX_DEPTH);
1873 assert_eq!(clamp_depth(0), 1);
1874 }
1875
1876 #[test]
1877 fn the_staged_socket_sits_beside_the_published_one() {
1878 let socket = Path::new("/run/recall-echo/abc.sock");
1879 let staged = staging_path(socket);
1880 assert_eq!(staged.parent(), socket.parent());
1881 assert_ne!(staged, socket);
1882 assert!(staged.as_os_str().len() - socket.as_os_str().len() <= 7);
1885 }
1886
1887 #[test]
1888 fn pidfile_sits_beside_the_socket() {
1889 assert_eq!(
1890 pidfile_path(Path::new("/run/recall-echo/abc.sock")),
1891 PathBuf::from("/run/recall-echo/abc.sock.pid")
1892 );
1893 }
1894
1895 #[test]
1896 fn serve_options_from_config_uses_defaults() {
1897 let dir = tempfile::tempdir().unwrap();
1898 std::fs::write(
1899 dir.path().join(".recall-echo.toml"),
1900 format!(
1901 "[serve]\nsocket_path = \"{}/graph.sock\"\n",
1902 dir.path().display()
1903 ),
1904 )
1905 .unwrap();
1906
1907 let options = ServeOptions::from_config(dir.path(), false).unwrap();
1908 assert_eq!(options.idle_timeout, Some(Duration::from_secs(3600)));
1909 assert!(!options.log_to_stderr);
1910 assert_eq!(options.socket_path, dir.path().join("graph.sock"));
1911 }
1912
1913 #[test]
1914 fn foreground_disables_idle_shutdown() {
1915 let dir = tempfile::tempdir().unwrap();
1916 std::fs::write(
1917 dir.path().join(".recall-echo.toml"),
1918 format!(
1919 "[serve]\nsocket_path = \"{}/graph.sock\"\nidle_timeout_secs = 30\n",
1920 dir.path().display()
1921 ),
1922 )
1923 .unwrap();
1924
1925 let options = ServeOptions::from_config(dir.path(), true).unwrap();
1926 assert_eq!(options.idle_timeout, None);
1927 assert!(options.log_to_stderr);
1928 }
1929
1930 #[test]
1931 fn zero_idle_timeout_disables_idle_shutdown() {
1932 let dir = tempfile::tempdir().unwrap();
1933 std::fs::write(
1934 dir.path().join(".recall-echo.toml"),
1935 format!(
1936 "[serve]\nsocket_path = \"{}/graph.sock\"\nidle_timeout_secs = 0\n",
1937 dir.path().display()
1938 ),
1939 )
1940 .unwrap();
1941
1942 assert_eq!(
1943 ServeOptions::from_config(dir.path(), false)
1944 .unwrap()
1945 .idle_timeout,
1946 None
1947 );
1948 }
1949}