1use std::collections::{HashMap, HashSet};
27use std::path::PathBuf;
28use std::sync::Arc;
29use std::time::{Duration, SystemTime};
30
31use async_trait::async_trait;
32
33use crate::capture::{self, CaptureOptions};
34use crate::error::RecallError;
35use crate::graph::{GraphMemory, IngestContext};
36use crate::serve::{BackgroundGuard, DaemonLog, IdleTracker, ShutdownSignal};
37use crate::transcript::{Source, Transcript, TranscriptRef};
38
39const BATCH_SIZE: usize = 5;
41const MAX_POLL: Duration = Duration::from_secs(30);
43const MIN_POLL: Duration = Duration::from_millis(100);
46const MAX_ATTEMPTS: u32 = 2;
50
51#[async_trait]
58pub trait CaptureUnit: Send + Sync {
59 async fn pending(&self, limit: usize) -> Vec<TranscriptRef>;
62
63 async fn import(&self, transcript: &TranscriptRef) -> Result<u32, RecallError>;
66
67 async fn mark_swept(&self, transcript: &TranscriptRef);
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct Schedule {
77 pub idle_after: Duration,
79 pub poll_interval: Duration,
81}
82
83impl Schedule {
84 #[must_use]
90 pub fn after(idle_after: Duration) -> Self {
91 Self {
92 idle_after,
93 poll_interval: (idle_after / 4).clamp(MIN_POLL, MAX_POLL),
94 }
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum Plan {
101 Run(Schedule, Vec<Source>),
103 Off(String),
105}
106
107#[must_use]
114pub fn plan(config: &crate::config::Config, graph_mode: &str, sources: Vec<Source>) -> Plan {
115 if !config.capture.enabled {
116 return Plan::Off("[capture] enabled = false".into());
117 }
118 if graph_mode == "server" {
119 return Plan::Off("[graph] mode = \"server\" — use `recall-echo ingest`".into());
120 }
121 if sources.is_empty() {
122 return Plan::Off("no agent CLI transcripts found on this machine".into());
123 }
124 Plan::Run(Schedule::after(config.extraction.idle_after()), sources)
125}
126
127pub struct WorkerContext {
131 pub idle: Arc<IdleTracker>,
132 pub shutdown: Arc<ShutdownSignal>,
133 pub log: Arc<DaemonLog>,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138enum SweepOutcome {
139 NoWork,
141 Worked,
143 Stopped,
145}
146
147pub struct CaptureWorker {
149 schedule: Schedule,
150 context: WorkerContext,
151 attempts: HashMap<PathBuf, u32>,
153 skipped: HashSet<PathBuf>,
155}
156
157impl CaptureWorker {
158 #[must_use]
159 pub fn new(schedule: Schedule, context: WorkerContext) -> Self {
160 Self {
161 schedule,
162 context,
163 attempts: HashMap::new(),
164 skipped: HashSet::new(),
165 }
166 }
167
168 pub async fn run(mut self, unit: Arc<dyn CaptureUnit>) {
170 loop {
171 if self
172 .context
173 .shutdown
174 .sleep_until_stopped(self.schedule.poll_interval)
175 .await
176 {
177 return;
178 }
179 if !self.is_quiet() {
180 continue;
181 }
182 match self.run_sweep(unit.as_ref()).await {
183 SweepOutcome::NoWork | SweepOutcome::Worked => {}
184 SweepOutcome::Stopped => return,
185 }
186 }
187 }
188
189 fn is_quiet(&self) -> bool {
190 self.context
191 .idle
192 .is_quiet_at(std::time::Instant::now(), self.schedule.idle_after)
193 }
194
195 async fn run_sweep(&mut self, unit: &dyn CaptureUnit) -> SweepOutcome {
197 let Some(pending) = self.take_pending(unit).await else {
198 return SweepOutcome::Stopped;
199 };
200 if pending.is_empty() {
201 return SweepOutcome::NoWork;
202 }
203
204 let _busy = BackgroundGuard::new(Arc::clone(&self.context.idle));
206 let mut outcome = SweepOutcome::NoWork;
207
208 for transcript in pending {
209 if self.context.shutdown.is_triggered() {
210 return SweepOutcome::Stopped;
211 }
212 if self.context.idle.has_connections() {
214 break;
215 }
216
217 match self.context.shutdown.guard(unit.import(&transcript)).await {
218 None => return SweepOutcome::Stopped,
219 Some(Ok(log_number)) => {
220 unit.mark_swept(&transcript).await;
221 self.attempts.remove(&transcript.path);
222 if log_number > 0 {
223 outcome = SweepOutcome::Worked;
224 self.context.log.log(&format!(
225 "captured {} session {} as log {log_number:03}",
226 transcript.source, transcript.session_id
227 ));
228 }
229 }
230 Some(Err(err)) => {
231 self.record_failure(unit, &transcript, &err).await;
235 break;
236 }
237 }
238
239 tokio::task::yield_now().await;
242 }
243
244 outcome
245 }
246
247 async fn take_pending(&mut self, unit: &dyn CaptureUnit) -> Option<Vec<TranscriptRef>> {
249 let limit = BATCH_SIZE + self.skipped.len();
252 let pending = self.context.shutdown.guard(unit.pending(limit)).await?;
253 Some(
254 pending
255 .into_iter()
256 .filter(|transcript| !self.skipped.contains(&transcript.path))
257 .take(BATCH_SIZE)
258 .collect(),
259 )
260 }
261
262 async fn record_failure(
264 &mut self,
265 unit: &dyn CaptureUnit,
266 transcript: &TranscriptRef,
267 err: &RecallError,
268 ) {
269 let attempts = self.attempts.entry(transcript.path.clone()).or_insert(0);
270 *attempts += 1;
271 if *attempts >= MAX_ATTEMPTS {
272 self.skipped.insert(transcript.path.clone());
273 unit.mark_swept(transcript).await;
274 self.context.log.log(&format!(
275 "capture gave up on {} session {} after {MAX_ATTEMPTS} attempts: {err}",
276 transcript.source, transcript.session_id
277 ));
278 } else {
279 self.context.log.log(&format!(
280 "capture failed on {} session {}: {err}",
281 transcript.source, transcript.session_id
282 ));
283 }
284 }
285}
286
287pub struct GraphCaptureUnit {
295 memory_dir: PathBuf,
296 graph: Arc<GraphMemory>,
297 adapters: Vec<Box<dyn Transcript>>,
298 settle: Duration,
299}
300
301impl GraphCaptureUnit {
302 #[must_use]
303 pub fn new(
304 memory_dir: PathBuf,
305 graph: Arc<GraphMemory>,
306 adapters: Vec<Box<dyn Transcript>>,
307 settle: Duration,
308 ) -> Self {
309 Self {
310 memory_dir,
311 graph,
312 adapters,
313 settle,
314 }
315 }
316
317 fn options(&self) -> CaptureOptions {
318 CaptureOptions {
319 settle: self.settle,
320 now: SystemTime::now(),
321 }
322 }
323
324 fn adapter_for(&self, source: Source) -> Option<&dyn Transcript> {
325 self.adapters
326 .iter()
327 .find(|adapter| adapter.source() == source)
328 .map(AsRef::as_ref)
329 }
330}
331
332#[async_trait]
333impl CaptureUnit for GraphCaptureUnit {
334 async fn pending(&self, limit: usize) -> Vec<TranscriptRef> {
335 let archived = capture::archived_sessions(&self.memory_dir);
336 let options = self.options();
337 let mut ready = Vec::new();
338 for adapter in &self.adapters {
339 match capture::pending(&self.memory_dir, adapter.as_ref(), &archived, options) {
340 Ok(found) => ready.extend(found.ready),
341 Err(err) => eprintln!("recall-echo: capture discovery failed: {err}"),
342 }
343 }
344 ready.sort_by_key(|transcript| transcript.modified);
345 ready.truncate(limit);
346 ready
347 }
348
349 async fn import(&self, transcript: &TranscriptRef) -> Result<u32, RecallError> {
350 let adapter = self
351 .adapter_for(transcript.source)
352 .ok_or_else(|| RecallError::Other(format!("no adapter for {}", transcript.source)))?;
353 let archived = capture::archived_sessions(&self.memory_dir);
354 let Some(result) =
355 capture::archive_transcript(&self.memory_dir, adapter, transcript, &archived)?
356 else {
357 return Ok(0);
358 };
359 if result.log_number == 0 {
360 return Ok(0);
361 }
362
363 let context = IngestContext::new(result.session_id.clone(), Some(result.log_number));
364 self.graph
365 .ingest_archive(&result.full_content, &context, None)
366 .await?;
367 Ok(result.log_number)
368 }
369
370 async fn mark_swept(&self, transcript: &TranscriptRef) {
371 capture::write_watermark(&self.memory_dir, transcript.source, transcript.modified);
372 }
373}
374
375pub struct Setup {
379 pub memory_dir: PathBuf,
380 pub graph: Arc<GraphMemory>,
381 pub idle: Arc<IdleTracker>,
382 pub shutdown: Arc<ShutdownSignal>,
383 pub log: Arc<DaemonLog>,
384}
385
386pub fn spawn(setup: Setup) -> Option<tokio::task::JoinHandle<()>> {
390 let config = crate::config::load_from_dir(&setup.memory_dir);
391 let mode = crate::serve_client::graph_mode(&setup.memory_dir);
392
393 let sources = capture::configured_sources(&config.capture);
394 let (schedule, sources) = match plan(&config, &mode, sources) {
395 Plan::Run(schedule, sources) => (schedule, sources),
396 Plan::Off(reason) => {
397 setup.log.log(&format!("background capture off: {reason}"));
398 return None;
399 }
400 };
401
402 if !setup.memory_dir.join("conversations").exists() {
403 setup
404 .log
405 .log("background capture off: no conversations/ directory to archive into");
406 return None;
407 }
408
409 let adapters: Vec<Box<dyn Transcript>> = sources
410 .iter()
411 .filter_map(|source| crate::transcript::adapter_for(*source))
412 .collect();
413 if adapters.is_empty() {
414 setup
415 .log
416 .log("background capture off: none of the configured CLIs could be located");
417 return None;
418 }
419
420 let names: Vec<String> = adapters
421 .iter()
422 .map(|adapter| adapter.source().to_string())
423 .collect();
424 setup.log.log(&format!(
425 "background capture on: {}, every {}s of quiet, transcripts idle for {}s",
426 names.join(", "),
427 schedule.idle_after.as_secs(),
428 config.capture.settle_secs,
429 ));
430
431 let unit: Arc<dyn CaptureUnit> = Arc::new(GraphCaptureUnit::new(
432 setup.memory_dir.clone(),
433 Arc::clone(&setup.graph),
434 adapters,
435 config.capture.settle(),
436 ));
437 let worker = CaptureWorker::new(
438 schedule,
439 WorkerContext {
440 idle: setup.idle,
441 shutdown: setup.shutdown,
442 log: setup.log,
443 },
444 );
445 Some(tokio::spawn(worker.run(unit)))
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use std::sync::Mutex;
452
453 fn config(enabled: bool) -> crate::config::Config {
454 crate::config::Config {
455 capture: crate::config::CaptureSection {
456 enabled,
457 ..crate::config::CaptureSection::default()
458 },
459 ..crate::config::Config::default()
460 }
461 }
462
463 #[test]
464 fn the_default_plan_sweeps_the_installed_clis() {
465 let Plan::Run(schedule, sources) = plan(&config(true), "embedded", vec![Source::Codex])
466 else {
467 panic!("the default config must sweep");
468 };
469 assert_eq!(schedule.idle_after, Duration::from_secs(120));
470 assert_eq!(schedule.poll_interval, Duration::from_secs(30));
471 assert_eq!(sources, vec![Source::Codex]);
472 }
473
474 #[test]
475 fn opting_out_turns_the_worker_off() {
476 let Plan::Off(reason) = plan(&config(false), "embedded", vec![Source::Codex]) else {
477 panic!("enabled = false must be honored");
478 };
479 assert!(reason.contains("enabled"), "{reason}");
480 }
481
482 #[test]
483 fn server_mode_never_captures_in_the_background() {
484 let Plan::Off(reason) = plan(&config(true), "server", vec![Source::Codex]) else {
485 panic!("server mode has no daemon to schedule against");
486 };
487 assert!(reason.contains("server"), "{reason}");
488 }
489
490 #[test]
491 fn a_machine_with_no_agent_clis_has_nothing_to_sweep() {
492 let Plan::Off(reason) = plan(&config(true), "embedded", Vec::new()) else {
493 panic!("no sources means no worker");
494 };
495 assert!(reason.contains("no agent CLI"), "{reason}");
496 }
497
498 #[derive(Default)]
501 struct FakeState {
502 imported: Vec<String>,
503 swept: Vec<String>,
504 failing: HashSet<String>,
505 }
506
507 struct FakeUnit {
508 transcripts: Vec<TranscriptRef>,
509 state: Mutex<FakeState>,
510 }
511
512 impl FakeUnit {
513 fn new(ids: &[&str], failing: &[&str]) -> Self {
514 let transcripts = ids
515 .iter()
516 .enumerate()
517 .map(|(index, id)| TranscriptRef {
518 source: Source::Codex,
519 session_id: (*id).to_string(),
520 path: PathBuf::from(format!("/tmp/{id}.jsonl")),
521 modified: SystemTime::UNIX_EPOCH + Duration::from_secs(index as u64),
522 cwd: None,
523 })
524 .collect();
525 Self {
526 transcripts,
527 state: Mutex::new(FakeState {
528 failing: failing.iter().map(|id| (*id).to_string()).collect(),
529 ..FakeState::default()
530 }),
531 }
532 }
533
534 fn imported(&self) -> Vec<String> {
535 self.state.lock().unwrap().imported.clone()
536 }
537
538 fn swept(&self) -> Vec<String> {
539 self.state.lock().unwrap().swept.clone()
540 }
541 }
542
543 #[async_trait]
544 impl CaptureUnit for FakeUnit {
545 async fn pending(&self, limit: usize) -> Vec<TranscriptRef> {
546 let swept = self.state.lock().unwrap().swept.clone();
547 self.transcripts
548 .iter()
549 .filter(|t| !swept.contains(&t.session_id))
550 .take(limit)
551 .cloned()
552 .collect()
553 }
554
555 async fn import(&self, transcript: &TranscriptRef) -> Result<u32, RecallError> {
556 let mut state = self.state.lock().unwrap();
557 if state.failing.contains(&transcript.session_id) {
558 return Err(RecallError::Other("unreadable".into()));
559 }
560 state.imported.push(transcript.session_id.clone());
561 Ok(state.imported.len() as u32)
562 }
563
564 async fn mark_swept(&self, transcript: &TranscriptRef) {
565 self.state
566 .lock()
567 .unwrap()
568 .swept
569 .push(transcript.session_id.clone());
570 }
571 }
572
573 fn worker(idle: &Arc<IdleTracker>, shutdown: &Arc<ShutdownSignal>) -> CaptureWorker {
574 CaptureWorker::new(
575 Schedule::after(Duration::from_secs(0)),
576 WorkerContext {
577 idle: Arc::clone(idle),
578 shutdown: Arc::clone(shutdown),
579 log: Arc::new(DaemonLog::open(std::path::Path::new("/dev/null"), false)),
581 },
582 )
583 }
584
585 #[tokio::test]
586 async fn a_sweep_imports_and_marks_every_ready_transcript() {
587 let idle = Arc::new(IdleTracker::new(None));
588 let shutdown = Arc::new(ShutdownSignal::new());
589 let unit = FakeUnit::new(&["a", "b"], &[]);
590
591 let mut worker = worker(&idle, &shutdown);
592 assert_eq!(worker.run_sweep(&unit).await, SweepOutcome::Worked);
593
594 assert_eq!(unit.imported(), ["a", "b"]);
595 assert_eq!(unit.swept(), ["a", "b"]);
596 }
597
598 #[tokio::test]
599 async fn nothing_to_import_is_not_work() {
600 let idle = Arc::new(IdleTracker::new(None));
601 let shutdown = Arc::new(ShutdownSignal::new());
602 let unit = FakeUnit::new(&[], &[]);
603
604 let mut worker = worker(&idle, &shutdown);
605 assert_eq!(worker.run_sweep(&unit).await, SweepOutcome::NoWork);
606 }
607
608 #[tokio::test]
611 async fn a_failure_stops_the_sweep_without_marking_it_swept() {
612 let idle = Arc::new(IdleTracker::new(None));
613 let shutdown = Arc::new(ShutdownSignal::new());
614 let unit = FakeUnit::new(&["a", "bad", "c"], &["bad"]);
615
616 let mut worker = worker(&idle, &shutdown);
617 worker.run_sweep(&unit).await;
618
619 assert_eq!(unit.imported(), ["a"]);
620 assert_eq!(unit.swept(), ["a"]);
621 }
622
623 #[tokio::test]
625 async fn a_transcript_that_never_imports_is_given_up_on() {
626 let idle = Arc::new(IdleTracker::new(None));
627 let shutdown = Arc::new(ShutdownSignal::new());
628 let unit = FakeUnit::new(&["bad", "c"], &["bad"]);
629
630 let mut worker = worker(&idle, &shutdown);
631 for _ in 0..MAX_ATTEMPTS {
632 worker.run_sweep(&unit).await;
633 }
634 worker.run_sweep(&unit).await;
635
636 assert_eq!(unit.imported(), ["c"]);
637 assert!(unit.swept().contains(&"bad".to_string()));
638 }
639
640 #[tokio::test]
641 async fn shutdown_ends_the_sweep_immediately() {
642 let idle = Arc::new(IdleTracker::new(None));
643 let shutdown = Arc::new(ShutdownSignal::new());
644 shutdown.trigger();
645 let unit = FakeUnit::new(&["a"], &[]);
646
647 let mut worker = worker(&idle, &shutdown);
648 assert_eq!(worker.run_sweep(&unit).await, SweepOutcome::Stopped);
649 assert!(unit.imported().is_empty());
650 }
651
652 #[tokio::test]
655 async fn a_sweep_does_not_disturb_the_quiet_clock() {
656 let start = std::time::Instant::now();
657 let idle = Arc::new(IdleTracker::new_at(None, start));
658 let shutdown = Arc::new(ShutdownSignal::new());
659 let unit = FakeUnit::new(&["a", "b"], &[]);
660
661 let mut worker = worker(&idle, &shutdown);
662 worker.run_sweep(&unit).await;
663
664 assert!(idle.is_quiet_at(start + Duration::from_secs(120), Duration::from_secs(60)));
666 }
667}