lore_cli/daemon/
mod.rs

1//! Background daemon for automatic session capture.
2//!
3//! The daemon watches for Claude Code session files and automatically
4//! imports them into the Lore database. It provides:
5//!
6//! - File watching for `~/.claude/projects/` directory
7//! - Incremental parsing of session files
8//! - Unix socket IPC for CLI communication
9//! - Graceful shutdown handling
10//!
11//! # Architecture
12//!
13//! The daemon consists of three main components:
14//!
15//! - **Watcher**: Monitors the file system for new/modified session files
16//! - **Server**: Handles IPC commands from CLI (status, stop, stats)
17//! - **State**: Manages PID file, socket path, and runtime state
18//!
19//! # Usage
20//!
21//! The daemon is typically started via `lore daemon start` and can be
22//! stopped via `lore daemon stop`. Use `lore daemon status` to check
23//! if the daemon is running.
24
25pub mod server;
26pub mod state;
27pub mod watcher;
28
29use anyhow::Result;
30use std::sync::Arc;
31use tokio::signal;
32use tokio::sync::{oneshot, RwLock};
33use tracing_appender::non_blocking::WorkerGuard;
34
35use crate::config::Config;
36
37pub use server::{send_command_sync, DaemonCommand, DaemonResponse};
38pub use state::{DaemonState, DaemonStats};
39pub use watcher::SessionWatcher;
40
41/// Runs the daemon in the foreground.
42///
43/// This is the main entry point for the daemon. It:
44/// 1. Checks if another instance is already running
45/// 2. Sets up logging to a file
46/// 3. Writes the PID file
47/// 4. Starts the file watcher and IPC server
48/// 5. Waits for shutdown signal (SIGTERM/SIGINT or stop command)
49/// 6. Cleans up state files on exit
50///
51/// # Errors
52///
53/// Returns an error if:
54/// - Another daemon instance is already running
55/// - The database cannot be opened
56/// - The watcher or server fails to start
57pub async fn run_daemon() -> Result<()> {
58    let state = DaemonState::new()?;
59
60    // Check if already running
61    if state.is_running() {
62        anyhow::bail!(
63            "Daemon is already running (PID {})",
64            state.get_pid().unwrap_or(0)
65        );
66    }
67
68    // Check if lore has been initialized
69    let config_path = Config::config_path()?;
70    if !config_path.exists() {
71        eprintln!(
72            "Error: Lore has not been initialized.\n\n\
73            Run 'lore init' first to:\n  \
74            - Select which AI tools to watch\n  \
75            - Configure your machine identity\n  \
76            - Import existing sessions\n\n\
77            Then start the daemon with 'lore daemon start' or let init do it for you."
78        );
79        // Exit with code 0 so launchd doesn't treat this as a crash and restart
80        std::process::exit(0);
81    }
82
83    // Set up file logging
84    let _guard = setup_logging(&state)?;
85
86    tracing::info!("Starting Lore daemon...");
87
88    // Write PID file
89    let pid = std::process::id();
90    state.write_pid(pid)?;
91    tracing::info!("Daemon started with PID {}", pid);
92
93    // Create shared stats
94    let stats = Arc::new(RwLock::new(DaemonStats::default()));
95
96    // Create shutdown channels
97    let (stop_tx, stop_rx) = oneshot::channel::<()>();
98    let (broadcast_tx, _) = tokio::sync::broadcast::channel::<()>(1);
99
100    // Start the IPC server
101    let server_stats = stats.clone();
102    let socket_path = state.socket_path.clone();
103    let server_broadcast_rx = broadcast_tx.subscribe();
104    let server_handle = tokio::spawn(async move {
105        if let Err(e) = server::run_server(
106            &socket_path,
107            server_stats,
108            Some(stop_tx),
109            server_broadcast_rx,
110        )
111        .await
112        {
113            tracing::error!("IPC server error: {}", e);
114        }
115    });
116
117    // Start the file watcher
118    let mut watcher = SessionWatcher::new()?;
119    let watcher_stats = stats.clone();
120    let watcher_broadcast_rx = broadcast_tx.subscribe();
121    let watcher_handle = tokio::spawn(async move {
122        if let Err(e) = watcher.watch(watcher_stats, watcher_broadcast_rx).await {
123            tracing::error!("Watcher error: {}", e);
124        }
125    });
126
127    // Wait for shutdown signal
128    tokio::select! {
129        _ = signal::ctrl_c() => {
130            tracing::info!("Received Ctrl+C, shutting down...");
131        }
132        _ = stop_rx => {
133            tracing::info!("Received stop command, shutting down...");
134        }
135    }
136
137    // Signal all components to shut down
138    let _ = broadcast_tx.send(());
139
140    // Give components time to clean up
141    tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
142
143    // Abort handles if they haven't finished
144    server_handle.abort();
145    watcher_handle.abort();
146
147    // Clean up state files
148    state.cleanup()?;
149
150    tracing::info!("Daemon stopped");
151
152    Ok(())
153}
154
155/// Sets up file logging for the daemon.
156///
157/// Configures tracing to write logs to `~/.lore/daemon.log`.
158/// Returns a guard that must be kept alive for the duration of the daemon.
159/// If a global subscriber is already set (e.g., from main.rs when running
160/// in foreground mode), this will log to the existing subscriber.
161fn setup_logging(state: &DaemonState) -> Result<WorkerGuard> {
162    use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
163
164    let file_appender = tracing_appender::rolling::never(
165        state.log_file.parent().unwrap_or(std::path::Path::new(".")),
166        state.log_file.file_name().unwrap_or_default(),
167    );
168    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
169
170    let file_layer = tracing_subscriber::fmt::layer()
171        .with_writer(non_blocking)
172        .with_ansi(false);
173
174    // Use try_init to avoid panic if a subscriber is already set
175    // (which happens when running in foreground from CLI)
176    let _ = tracing_subscriber::registry()
177        .with(
178            tracing_subscriber::EnvFilter::try_from_default_env()
179                .unwrap_or_else(|_| "lore=info".into()),
180        )
181        .with(file_layer)
182        .try_init();
183
184    Ok(guard)
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_daemon_state_paths() {
193        // Just verify DaemonState can be created
194        let state = DaemonState::new();
195        assert!(state.is_ok(), "DaemonState creation should succeed");
196
197        let state = state.unwrap();
198        assert!(
199            state.pid_file.to_string_lossy().contains("daemon.pid"),
200            "PID file path should contain daemon.pid"
201        );
202        assert!(
203            state.socket_path.to_string_lossy().contains("daemon.sock"),
204            "Socket path should contain daemon.sock"
205        );
206        assert!(
207            state.log_file.to_string_lossy().contains("daemon.log"),
208            "Log file path should contain daemon.log"
209        );
210    }
211}