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}