claude_code_toolkit/daemon/
mod.rs

1//! Background daemon service for automatic Claude Code credential synchronization.
2//!
3//! This module provides a long-running daemon service that monitors Claude Code credentials
4//! for changes and automatically synchronizes them to configured GitHub targets. The daemon
5//! runs as a systemd user service and provides intelligent monitoring, scheduling, and
6//! notification features.
7//!
8//! ## Core Features
9//!
10//! - **Automatic Monitoring**: Watches for credential changes and expiration
11//! - **Smart Scheduling**: Syncs immediately after token refresh with configurable delays
12//! - **Session Warnings**: Desktop notifications before session expiry
13//! - **Error Recovery**: Robust error handling with failure notifications
14//! - **Signal Handling**: Graceful shutdown on SIGINT/SIGTERM
15//! - **Startup Recovery**: Reconciliation check on daemon startup
16//!
17//! ## Daemon Lifecycle
18//!
19//! 1. **Startup**: Perform initial sync check and reconciliation
20//! 2. **Monitoring Loop**: Check credentials every 5 minutes, session warnings every minute
21//! 3. **Token Expiry**: Wait for refresh, then sync to all targets
22//! 4. **Notifications**: Send warnings before expiry, errors on sync failures
23//! 5. **Shutdown**: Graceful cleanup on shutdown signals
24//!
25//! ## Usage Examples
26//!
27//! ### Basic Daemon Usage
28//!
29//! ```rust,no_run
30//! use claude_code_toolkit::daemon::Daemon;
31//!
32//! #[tokio::main]
33//! async fn main() -> claude_code_toolkit::Result<()> {
34//!     // Initialize daemon with configuration
35//!     let mut daemon = Daemon::new_with_config().await?;
36//!     
37//!     // Start the main daemon loop (runs indefinitely)
38//!     daemon.start().await?;
39//!     
40//!     Ok(())
41//! }
42//! ```
43//!
44//! ### One-time Check
45//!
46//! ```rust,no_run
47//! use claude_code_toolkit::daemon::Daemon;
48//!
49//! #[tokio::main]
50//! async fn main() -> claude_code_toolkit::Result<()> {
51//!     let mut daemon = Daemon::new_with_config().await?;
52//!     
53//!     // Run a single sync check without starting the daemon
54//!     daemon.run_once().await?;
55//!     
56//!     Ok(())
57//! }
58//! ```
59//!
60//! ## Configuration
61//!
62//! The daemon reads configuration from `~/.goodiebag/claude-code/config.yml`:
63//!
64//! ```yaml
65//! daemon:
66//!   log_level: info
67//!   sync_delay_after_expiry: 60  # seconds to wait after token expiry
68//!
69//! notifications:
70//!   session_warnings: [30, 15, 5]  # minutes before expiry
71//!   sync_failures: true
72//! ```
73//!
74//! ## Systemd Integration
75//!
76//! The daemon is designed to run as a systemd user service:
77//!
78//! ```ini
79//! [Unit]
80//! Description=Claude Code Credential Sync Daemon
81//!
82//! [Service]
83//! Type=simple
84//! ExecStart=/path/to/claude-code-toolkit daemon
85//! Restart=always
86//! RestartSec=10
87//!
88//! [Install]
89//! WantedBy=default.target
90//! ```
91//!
92//! ## Monitoring and Observability
93//!
94//! - **Structured Logging**: Uses `tracing` for detailed operation logs
95//! - **Desktop Notifications**: Visual feedback for important events
96//! - **Status Tracking**: Maintains sync state and error history
97//! - **Health Checks**: Validates configuration and connectivity on startup
98//!
99//! ## Error Handling
100//!
101//! The daemon implements comprehensive error handling:
102//! - Individual sync failures don't stop the daemon
103//! - Network issues are retried automatically
104//! - Configuration errors are logged and reported
105//! - Service continues running even after transient failures
106
107use crate::{
108  config::{ credentials::CredentialsManager, manager::ConfigurationManager },
109  error::*,
110  sync::SyncService,
111  traits::config::ConfigManager,
112  utils::notifications,
113};
114use std::time::Duration;
115use tokio::signal;
116use tokio::time::{ interval, sleep };
117use tracing::{ error, info, warn };
118
119/// Main daemon service for background credential synchronization.
120///
121/// The `Daemon` orchestrates automatic credential monitoring and synchronization
122/// by running in the background as a systemd user service. It coordinates between
123/// credential monitoring, configuration management, and sync operations while
124/// providing robust error handling and observability.
125///
126/// ## Architecture
127///
128/// The daemon maintains:
129/// - [`SyncService`] - Handles the actual credential synchronization logic
130/// - [`ConfigurationManager`] - Manages YAML configuration and targets
131/// - [`CredentialsManager`] - Monitors Claude Code credential files
132/// - Shutdown coordination - Graceful termination handling
133///
134/// ## Monitoring Schedule
135///
136/// - **Credential checks**: Every 5 minutes (300 seconds)
137/// - **Session warnings**: Every 1 minute (60 seconds)  
138/// - **Post-expiry sync**: 30 seconds after detection
139/// - **Startup reconciliation**: Immediate on daemon start
140pub struct Daemon {
141  sync_service: SyncService,
142  config_manager: ConfigurationManager,
143  credentials_manager: CredentialsManager,
144  shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
145}
146
147impl Daemon {
148  pub fn new() -> Result<Self> {
149    Ok(Self {
150      sync_service: SyncService::new()?,
151      config_manager: ConfigurationManager::new()?,
152      credentials_manager: CredentialsManager::new()?,
153      shutdown_tx: None,
154    })
155  }
156
157  pub async fn new_with_config() -> Result<Self> {
158    let config_manager = ConfigurationManager::new()?;
159    let config = config_manager.load().await?;
160
161    // Expand tilde in path
162    let expanded_path = shellexpand::tilde(&config.credentials.file_path);
163    let credentials_path = std::path::PathBuf::from(expanded_path.as_ref());
164
165    Ok(Self {
166      sync_service: SyncService::new_with_config().await?,
167      config_manager,
168      credentials_manager: CredentialsManager::with_path(credentials_path),
169      shutdown_tx: None,
170    })
171  }
172
173  pub async fn start(&mut self) -> Result<()> {
174    info!("Claude Code daemon starting");
175
176    // Load config
177    let _config = self.config_manager.load_config().await?;
178
179    // Check and sync immediately on startup
180    if let Err(e) = self.sync_service.check_and_sync_if_needed().await {
181      error!("Startup sync failed: {}", e);
182    }
183
184    // Set up shutdown signal handling
185    let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
186    self.shutdown_tx = Some(shutdown_tx);
187
188    // Main daemon loop
189    let mut check_interval = interval(Duration::from_secs(300)); // Check every 5 minutes
190    let mut session_check_interval = interval(Duration::from_secs(60)); // Check session every minute
191
192    info!("Claude Code daemon started successfully");
193
194    loop {
195      tokio::select! {
196                // Shutdown signal received
197                _ = &mut shutdown_rx => {
198                    info!("Shutdown signal received");
199                    break;
200                }
201
202                // Periodic sync check
203                _ = check_interval.tick() => {
204                    if let Err(e) = self.check_token_expiry().await {
205                        error!("Periodic token check failed: {}", e);
206                    }
207                }
208
209                // Session warning check
210                _ = session_check_interval.tick() => {
211                    if let Err(e) = self.check_session_warnings().await {
212                        error!("Session warning check failed: {}", e);
213                    }
214                }
215
216                // Handle SIGINT and SIGTERM
217                _ = signal::ctrl_c() => {
218                    info!("Received Ctrl+C, shutting down");
219                    break;
220                }
221            }
222    }
223
224    info!("Claude Code daemon stopped");
225    Ok(())
226  }
227
228  pub async fn stop(&mut self) -> Result<()> {
229    if let Some(tx) = self.shutdown_tx.take() {
230      let _ = tx.send(());
231    }
232    Ok(())
233  }
234
235  async fn check_token_expiry(&mut self) -> Result<()> {
236    let session_info = self.credentials_manager.get_session_info().await?;
237
238    if session_info.is_expired {
239      info!("Token has expired, checking for refresh");
240
241      // Wait a bit for Claude Code to potentially refresh the token
242      sleep(Duration::from_secs(30)).await;
243
244      // Check if we need to sync
245      if let Err(e) = self.sync_service.check_and_sync_if_needed().await {
246        error!("Sync after expiry failed: {}", e);
247
248        // Send notification about sync failure
249        if let Err(notify_err) = notifications::send_sync_failure("all targets", &e.to_string()) {
250          warn!("Failed to send sync failure notification: {}", notify_err);
251        }
252      } else {
253        info!("Successfully synced after token expiry");
254      }
255    } else {
256      // Schedule next check around expiry time
257      let time_until_expiry = Duration::from_millis(session_info.time_remaining as u64);
258      let config = self.config_manager.load_config().await?;
259      let _sync_delay = Duration::from_secs(config.daemon.sync_delay_after_expiry);
260
261      if time_until_expiry < Duration::from_secs(600) {
262        // Less than 10 minutes
263        info!(
264          "Token expires soon ({}), will check again after expiry",
265          CredentialsManager::format_time_remaining(session_info.time_remaining)
266        );
267      }
268    }
269
270    Ok(())
271  }
272
273  async fn check_session_warnings(&self) -> Result<()> {
274    let session_info = self.credentials_manager.get_session_info().await?;
275
276    if session_info.is_expired {
277      return Ok(());
278    }
279
280    let config = self.config_manager.load_config().await?;
281    let time_remaining_minutes = session_info.time_remaining / 1000 / 60;
282
283    // Check if we should send a warning
284    for &warning_minutes in &config.notifications.session_warnings {
285      let warning_minutes = warning_minutes as i64;
286
287      // Send warning if we're within the warning window (with 1-minute tolerance)
288      if
289        time_remaining_minutes <= warning_minutes &&
290        time_remaining_minutes >= warning_minutes - 1
291      {
292        info!("Sending session warning: {} minutes remaining", warning_minutes);
293
294        if let Err(e) = notifications::send_session_warning(warning_minutes as u64) {
295          warn!("Failed to send session warning: {}", e);
296        }
297
298        break; // Only send one warning per check
299      }
300    }
301
302    Ok(())
303  }
304
305  pub async fn run_once(&mut self) -> Result<()> {
306    info!("Running daemon check once");
307
308    if let Err(e) = self.sync_service.check_and_sync_if_needed().await {
309      error!("One-time sync check failed: {}", e);
310      return Err(e);
311    }
312
313    info!("One-time daemon check completed");
314    Ok(())
315  }
316}