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
35pub use server::{send_command_sync, DaemonCommand, DaemonResponse};
36pub use state::{DaemonState, DaemonStats};
37pub use watcher::SessionWatcher;
38
39/// Runs the daemon in the foreground.
40///
41/// This is the main entry point for the daemon. It:
42/// 1. Checks if another instance is already running
43/// 2. Sets up logging to a file
44/// 3. Writes the PID file
45/// 4. Starts the file watcher and IPC server
46/// 5. Waits for shutdown signal (SIGTERM/SIGINT or stop command)
47/// 6. Cleans up state files on exit
48///
49/// # Errors
50///
51/// Returns an error if:
52/// - Another daemon instance is already running
53/// - The database cannot be opened
54/// - The watcher or server fails to start
55pub async fn run_daemon() -> Result<()> {
56    let state = DaemonState::new()?;
57
58    // Check if already running
59    if state.is_running() {
60        anyhow::bail!(
61            "Daemon is already running (PID {})",
62            state.get_pid().unwrap_or(0)
63        );
64    }
65
66    // Set up file logging
67    let _guard = setup_logging(&state)?;
68
69    tracing::info!("Starting Lore daemon...");
70
71    // Write PID file
72    let pid = std::process::id();
73    state.write_pid(pid)?;
74    tracing::info!("Daemon started with PID {}", pid);
75
76    // Create shared stats
77    let stats = Arc::new(RwLock::new(DaemonStats::default()));
78
79    // Create shutdown channels
80    let (stop_tx, stop_rx) = oneshot::channel::<()>();
81    let (broadcast_tx, _) = tokio::sync::broadcast::channel::<()>(1);
82
83    // Start the IPC server
84    let server_stats = stats.clone();
85    let socket_path = state.socket_path.clone();
86    let server_broadcast_rx = broadcast_tx.subscribe();
87    let server_handle = tokio::spawn(async move {
88        if let Err(e) = server::run_server(
89            &socket_path,
90            server_stats,
91            Some(stop_tx),
92            server_broadcast_rx,
93        )
94        .await
95        {
96            tracing::error!("IPC server error: {}", e);
97        }
98    });
99
100    // Start the file watcher
101    let mut watcher = SessionWatcher::new()?;
102    let watcher_stats = stats.clone();
103    let watcher_broadcast_rx = broadcast_tx.subscribe();
104    let watcher_handle = tokio::spawn(async move {
105        if let Err(e) = watcher.watch(watcher_stats, watcher_broadcast_rx).await {
106            tracing::error!("Watcher error: {}", e);
107        }
108    });
109
110    // Wait for shutdown signal
111    tokio::select! {
112        _ = signal::ctrl_c() => {
113            tracing::info!("Received Ctrl+C, shutting down...");
114        }
115        _ = stop_rx => {
116            tracing::info!("Received stop command, shutting down...");
117        }
118    }
119
120    // Signal all components to shut down
121    let _ = broadcast_tx.send(());
122
123    // Give components time to clean up
124    tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
125
126    // Abort handles if they haven't finished
127    server_handle.abort();
128    watcher_handle.abort();
129
130    // Clean up state files
131    state.cleanup()?;
132
133    tracing::info!("Daemon stopped");
134
135    Ok(())
136}
137
138/// Sets up file logging for the daemon.
139///
140/// Configures tracing to write logs to `~/.lore/daemon.log`.
141/// Returns a guard that must be kept alive for the duration of the daemon.
142/// If a global subscriber is already set (e.g., from main.rs when running
143/// in foreground mode), this will log to the existing subscriber.
144fn setup_logging(state: &DaemonState) -> Result<WorkerGuard> {
145    use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
146
147    let file_appender = tracing_appender::rolling::never(
148        state.log_file.parent().unwrap_or(std::path::Path::new(".")),
149        state.log_file.file_name().unwrap_or_default(),
150    );
151    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
152
153    let file_layer = tracing_subscriber::fmt::layer()
154        .with_writer(non_blocking)
155        .with_ansi(false);
156
157    // Use try_init to avoid panic if a subscriber is already set
158    // (which happens when running in foreground from CLI)
159    let _ = tracing_subscriber::registry()
160        .with(
161            tracing_subscriber::EnvFilter::try_from_default_env()
162                .unwrap_or_else(|_| "lore=info".into()),
163        )
164        .with(file_layer)
165        .try_init();
166
167    Ok(guard)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_daemon_state_paths() {
176        // Just verify DaemonState can be created
177        let state = DaemonState::new();
178        assert!(state.is_ok(), "DaemonState creation should succeed");
179
180        let state = state.unwrap();
181        assert!(
182            state.pid_file.to_string_lossy().contains("daemon.pid"),
183            "PID file path should contain daemon.pid"
184        );
185        assert!(
186            state.socket_path.to_string_lossy().contains("daemon.sock"),
187            "Socket path should contain daemon.sock"
188        );
189        assert!(
190            state.log_file.to_string_lossy().contains("daemon.log"),
191            "Log file path should contain daemon.log"
192        );
193    }
194}