Skip to main content

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