From b037627d8475eff496d04517206a868b291efb59 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:25:53 +0530 Subject: [PATCH] Feat/daemon setup (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add .mcp.json for MCP server configuration - Introduced `.mcp.json` with server details for managing MCP integrations - Defines `readme` server with HTTP type and URL endpoint configuration * fix: Update skills submodule with Telegram error handling improvements - Fixed setup flow showing false success when TDLib errors occurred - Improved async error handling in setup steps - Added client reset mechanisms for error recovery - Enhanced error messaging for better user experience - Cleaned up excessive debug logging while maintaining error logs Resolves issue where Telegram setup showed success modal despite underlying TDLib errors. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Add daemon lifecycle management system to frontend UI This implementation integrates the Rust daemon's health monitoring and lifecycle management into the React frontend. Key features include real-time health indicators, automatic start/error recovery, and detailed control panels, enhancing both user experience and operational reliability. * Enhance daemon health monitoring with detailed logging, event tracking, and state management improvements. * Add extensive logging for Tauri socket lifecycle and event listeners This update improves troubleshooting and debugging by adding detailed logs throughout Tauri socket initialization, event listener setup, and error handling processes. * Define global Tauri interface types in TypeScript * Fix daemon service management and health communication issues This comprehensive fix resolves two critical daemon-related issues: 1. **Launchctl "Input/output error" fix**: - Add intelligent state checking before service operations - Only load LaunchAgent if not already loaded - Treat "already running" as success, not failure - Add helper functions for cross-platform service state detection - Implement idempotent service start operations 2. **Daemon health communication fix**: - Add ALPHAHUMAN_DAEMON_INTERNAL environment variable to external service plist - Force external daemon to use file-based communication (daemon_state.json) - Implement file watching bridge in main app to emit Tauri events - Ensure frontend receives proper health updates from external daemon **Key Changes**: - Enhanced `start()` function with state checking across all platforms - Added `is_service_loaded_macos()`, `is_service_enabled_linux()`, `is_task_exists_windows()` - Modified macOS plist to include `ALPHAHUMAN_DAEMON_INTERNAL=false` - Added `watch_daemon_health_file()` function for external daemon communication - Updated daemon mode detection logic for cross-platform consistency - Added comprehensive logging for service management operations **Expected Results**: - No more "Input/output error" when clicking daemon start button - External daemon status shows "connected" instead of "disconnected" - Idempotent service operations (safe to run multiple times) - Single daemon process prevents resource conflicts - Cross-platform daemon service reliability 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Add `ALPHAHUMAN_DAEMON_INTERNAL` env variable to configuration documentation --------- Co-authored-by: Claude --- CLAUDE.md | 1 + docs/feat/daemon-lifecycle-management.md | 341 ++++++++++++++++++ skills | 2 +- src-tauri/src/alphahuman/daemon/mod.rs | 40 +- src-tauri/src/alphahuman/service/mod.rs | 112 +++++- src-tauri/src/commands/alphahuman.rs | 5 +- src-tauri/src/lib.rs | 121 +++++-- src/components/MiniSidebar.tsx | 93 +++-- .../daemon/DaemonHealthIndicator.tsx | 125 +++++++ src/components/daemon/DaemonHealthPanel.tsx | 283 +++++++++++++++ .../settings/panels/TauriCommandsPanel.tsx | 155 ++++++-- src/hooks/useDaemonHealth.ts | 198 ++++++++++ src/hooks/useDaemonLifecycle.ts | 266 ++++++++++++++ src/providers/SocketProvider.tsx | 53 ++- src/services/api/userApi.ts | 4 +- src/services/daemonHealthService.ts | 207 +++++++++++ src/store/daemonSlice.ts | 214 +++++++++++ src/store/index.ts | 2 + src/types/global.d.ts | 11 + src/utils/tauriSocket.ts | 36 +- 20 files changed, 2157 insertions(+), 112 deletions(-) create mode 100644 docs/feat/daemon-lifecycle-management.md create mode 100644 src/components/daemon/DaemonHealthIndicator.tsx create mode 100644 src/components/daemon/DaemonHealthPanel.tsx create mode 100644 src/hooks/useDaemonHealth.ts create mode 100644 src/hooks/useDaemonLifecycle.ts create mode 100644 src/services/daemonHealthService.ts create mode 100644 src/store/daemonSlice.ts create mode 100644 src/types/global.d.ts diff --git a/CLAUDE.md b/CLAUDE.md index e8703c02e..dba39db4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -219,6 +219,7 @@ Set in `.env` (Vite exposes `VITE_*` prefixed vars): | `VITE_TELEGRAM_BOT_ID` | Telegram bot numeric ID | | `VITE_SENTRY_DSN` | Sentry DSN for error reporting (optional) | | `VITE_DEBUG` | Debug mode flag | +| `ALPHAHUMAN_DAEMON_INTERNAL` | Force internal daemon mode (default: false, uses external services) | Production defaults are in `src/utils/config.ts`. diff --git a/docs/feat/daemon-lifecycle-management.md b/docs/feat/daemon-lifecycle-management.md new file mode 100644 index 000000000..4365626e4 --- /dev/null +++ b/docs/feat/daemon-lifecycle-management.md @@ -0,0 +1,341 @@ +# Daemon Lifecycle Management System + +**Status**: ✅ Implemented +**Version**: 1.0.0 +**Date**: February 2026 + +## Overview + +The Daemon Lifecycle Management System provides comprehensive frontend integration with the alphahuman Rust daemon, enabling real-time health monitoring, automatic lifecycle management, and user-friendly daemon controls throughout the application. + +## Background + +### Problem Statement + +The alphahuman application runs a sophisticated Rust daemon that manages critical backend services (gateway, channels, heartbeat, scheduler) and emits detailed health information every 5 seconds. However, the frontend previously had: + +- **Poor Visibility**: Daemon controls buried in developer console only +- **No Real-Time Monitoring**: Manual status checks with raw JSON output +- **No Automatic Management**: No startup detection or error recovery +- **Disconnected UX**: Health events from Rust were ignored by frontend +- **Manual Recovery**: Users had to manually restart failed services + +### Solution Overview + +This implementation creates a complete bridge between the Rust daemon's health system and the React frontend, providing: + +- Real-time health monitoring with visual indicators +- Automatic daemon startup and error recovery +- User-friendly health displays in main UI +- Enhanced developer tools with live status +- Coordinated daemon and socket connection management + +## Architecture + +### System Components + +``` +┌─────────────────────────────────────────────────────────────┐ +│ React Frontend │ +├─────────────────────────────────────────────────────────────┤ +│ UI Layer │ +│ ├── DaemonHealthIndicator (Main UI) │ +│ ├── DaemonHealthPanel (Detailed View) │ +│ └── Enhanced TauriCommandsPanel (Dev Tools) │ +├─────────────────────────────────────────────────────────────┤ +│ State Management │ +│ ├── daemonSlice.ts (Redux) │ +│ ├── useDaemonHealth.ts (React Hook) │ +│ └── useDaemonLifecycle.ts (Lifecycle Hook) │ +├─────────────────────────────────────────────────────────────┤ +│ Services │ +│ └── daemonHealthService.ts (Event Processing) │ +├─────────────────────────────────────────────────────────────┤ +│ Integration Layer │ +│ ├── tauriSocket.ts (Event Listening) │ +│ └── SocketProvider.tsx (Coordinated State) │ +└─────────────────────────────────────────────────────────────┘ + │ + Tauri Event Bridge + │ +┌─────────────────────────────────────────────────────────────┐ +│ Rust Daemon │ +├─────────────────────────────────────────────────────────────┤ +│ Health System (src-tauri/src/alphahuman/health/mod.rs) │ +│ ├── Component Health Tracking │ +│ ├── Health Snapshot Generation │ +│ └── Event Emission (every 5s) │ +├─────────────────────────────────────────────────────────────┤ +│ Daemon Supervisor (src-tauri/src/alphahuman/daemon/mod.rs) │ +│ ├── Component Management │ +│ ├── Automatic Restarts │ +│ └── Exponential Backoff │ +├─────────────────────────────────────────────────────────────┤ +│ Components │ +│ ├── Gateway (API Server) │ +│ ├── Channels (Communication) │ +│ ├── Heartbeat (Health Monitor) │ +│ └── Scheduler (Cron Jobs) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Event Flow + +1. **Health Generation**: Rust daemon generates health snapshots every 5 seconds +2. **Event Emission**: Health data emitted via `alphahuman:health` Tauri event +3. **Frontend Processing**: `daemonHealthService` receives and parses events +4. **State Updates**: Redux state updated with component health information +5. **UI Reactivity**: React components automatically re-render with new status +6. **User Actions**: Manual controls trigger Tauri commands back to Rust + +## Implementation Details + +### Redux State Management + +**File**: `src/store/daemonSlice.ts` + +```typescript +interface DaemonUserState { + status: 'starting' | 'running' | 'error' | 'disconnected'; + healthSnapshot: HealthSnapshot | null; + components: { + gateway?: ComponentHealth; + channels?: ComponentHealth; + heartbeat?: ComponentHealth; + scheduler?: ComponentHealth; + }; + lastHealthUpdate: string | null; + connectionAttempts: number; + autoStartEnabled: boolean; + isRecovering: boolean; + healthTimeoutId: string | null; +} +``` + +**Key Features**: +- Per-user daemon state isolation (following existing patterns) +- Component-level health tracking with error details +- Connection attempt management with exponential backoff +- Auto-start preference persistence +- Timeout handling for health event detection + +### Health Monitoring Service + +**File**: `src/services/daemonHealthService.ts` + +```typescript +export class DaemonHealthService { + private healthTimeoutId: NodeJS.Timeout | null = null; + private readonly HEALTH_TIMEOUT_MS = 30000; // 30 seconds + + async setupHealthListener(): Promise + private parseHealthSnapshot(payload: unknown): HealthSnapshot | null + private updateReduxFromHealth(snapshot: HealthSnapshot): void + startHealthTimeout(): void +} +``` + +**Responsibilities**: +- Listen for `alphahuman:health` Tauri events from Rust +- Parse and validate health snapshot data +- Update Redux state with component health information +- Manage 30-second timeout detection for disconnected daemon +- Handle cleanup and error recovery + +### UI Components + +#### DaemonHealthIndicator + +**File**: `src/components/daemon/DaemonHealthIndicator.tsx` + +**Purpose**: Compact status indicator for main application UI + +**Visual States**: +- 🟢 **Green**: All components running healthy (`status: 'running'`) +- 🟡 **Yellow**: Daemon starting or recovering (`status: 'starting'`) +- 🔴 **Red**: One or more components in error state (`status: 'error'`) +- ⚪ **Gray**: Daemon disconnected or not running (`status: 'disconnected'`) + +**Features**: +- Click to open detailed health panel +- Tooltip showing component health summary +- Responsive sizing (sm/md/lg variants) +- Only visible in Tauri environments + +#### DaemonHealthPanel + +**File**: `src/components/daemon/DaemonHealthPanel.tsx` + +**Purpose**: Detailed health breakdown and manual controls + +**Features**: +- Component health table with status, last update, restart counts +- Manual restart buttons for individual components +- Auto-start toggle with persistence +- Connection retry controls +- Real-time health information display +- User-friendly error messages and troubleshooting hints + +### Lifecycle Management + +**File**: `src/hooks/useDaemonLifecycle.ts` + +**Automatic Behaviors**: + +1. **App Startup**: + - Detect existing daemon status + - Auto-start daemon if enabled and not running + - Setup health monitoring listeners + +2. **Error Recovery**: + - Exponential backoff retry attempts (1s → 2s → 4s → 8s → 30s max) + - Maximum 5 retry attempts before requiring manual intervention + - Clear error states on successful recovery + +3. **Background Handling**: + - Maintain health monitoring when app backgrounded + - Reconnection logic without page refresh + - Coordinate with socket connection management + +4. **Cleanup**: + - Proper event listener cleanup on unmount + - Timeout cancellation to prevent memory leaks + - Graceful shutdown coordination + +### Integration Points + +#### Enhanced Service Management + +**File**: `src/components/settings/panels/TauriCommandsPanel.tsx` + +**Improvements to Existing Developer Console**: + +- **Live Status Display**: Real-time daemon status with PID and uptime +- **Component Health Grid**: Visual status for all daemon components +- **Enhanced Controls**: Smart start/stop buttons with proper loading states +- **Auto-Start Toggle**: User preference for automatic daemon startup +- **Connection Tracking**: Display retry attempts and recovery status +- **Better Error Messages**: User-friendly errors with troubleshooting hints + +**Before vs After**: +```typescript +// Before: Manual status check + run(alphahumanServiceStatus, 'serviceStatus')}> + Status + + +// After: Live status display +
+ +
+
Daemon Status: {status}
+
PID: {pid} | Uptime: {uptime}
+
+
+``` + +#### Socket Provider Integration + +**File**: `src/providers/SocketProvider.tsx` + +**Coordinated State Management**: +- Check daemon health before attempting socket connections +- Display daemon-related errors in socket connection status +- Coordinate daemon startup with socket connection flows +- Provide daemon health context to socket consumers + +#### Main UI Integration + +**File**: `src/components/MiniSidebar.tsx` + +**User-Facing Integration**: +- Daemon health indicator in main navigation (Tauri-only) +- Click to open detailed health modal +- Non-intrusive but easily accessible +- Consistent with existing UI patterns + +## User Experience + +### For End Users + +1. **Visible Status**: Daemon health indicator in main UI shows system status at a glance +2. **Automatic Operation**: Daemon starts automatically and recovers from errors without user intervention +3. **Clear Feedback**: User-friendly messages explain daemon state and provide actionable guidance +4. **Quick Access**: Click health indicator to see detailed component status and manual controls + +### For Developers + +1. **Enhanced Console**: Improved service management in settings with live status updates +2. **Component Monitoring**: Real-time visibility into all daemon components (gateway, channels, etc.) +3. **Debug Information**: Detailed health snapshots, retry attempts, and error history +4. **Manual Override**: Full control over daemon lifecycle with proper state management + +### Error Handling + +1. **Graceful Degradation**: System works properly in non-Tauri environments +2. **Timeout Management**: 30-second timeout detection with automatic recovery attempts +3. **User Guidance**: Clear error messages with troubleshooting suggestions +4. **Recovery Actions**: Manual restart options when automatic recovery fails + +## Technical Benefits + +### Performance + +- **Efficient Updates**: Debounced Redux updates prevent excessive re-renders +- **Memory Management**: Proper cleanup of timeouts and event listeners +- **Background Optimization**: Minimal resource usage when app backgrounded + +### Reliability + +- **Timeout Handling**: Robust detection of daemon disconnection +- **Exponential Backoff**: Smart retry logic prevents resource exhaustion +- **State Consistency**: Coordinated daemon and socket connection states +- **Error Recovery**: Automatic recovery from transient failures + +### Maintainability + +- **TypeScript**: Full type safety throughout the implementation +- **Modular Design**: Clear separation between state, services, and UI components +- **Existing Patterns**: Follows established Redux and component patterns +- **Testing**: Comprehensive error handling and edge case management + +## Configuration + +### Auto-Start Behavior + +Users can control daemon auto-start behavior through: + +1. **UI Toggle**: Available in both health panel and settings console +2. **Persistence**: Preference stored in Redux with persistence +3. **Default**: Auto-start enabled by default for better UX + +### Health Monitoring + +- **Event Frequency**: Rust daemon emits health every 5 seconds +- **Timeout Duration**: 30 seconds without health events = disconnected +- **Retry Logic**: Maximum 5 attempts with exponential backoff +- **Component Tracking**: Gateway, channels, heartbeat, scheduler components + +## Future Enhancements + +### Potential Improvements + +1. **Health History**: Track daemon health over time with charts/graphs +2. **Performance Metrics**: CPU/memory usage from daemon components +3. **Log Integration**: Show daemon logs directly in health panel +4. **Mobile Optimization**: Enhanced mobile-specific daemon management +5. **Notification System**: Push notifications for critical daemon events + +### Extensibility + +The architecture supports easy extension for: +- Additional daemon components +- Custom health check logic +- Third-party integrations +- Advanced monitoring features + +## Conclusion + +The Daemon Lifecycle Management System provides a complete bridge between the sophisticated Rust daemon infrastructure and the React frontend, delivering excellent user experience while maintaining the technical robustness required for a production application. + +The implementation follows established patterns, provides comprehensive error handling, and creates a foundation for future daemon-related features while ensuring the system remains reliable and user-friendly. \ No newline at end of file diff --git a/skills b/skills index 0c6a11b1e..66ad4d7e3 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 0c6a11b1e05aed1d6c1cb986e81501f13afcf203 +Subproject commit 66ad4d7e3fd00122f24532e474d8b664b62bbd30 diff --git a/src-tauri/src/alphahuman/daemon/mod.rs b/src-tauri/src/alphahuman/daemon/mod.rs index 1766f4824..ba7047f78 100644 --- a/src-tauri/src/alphahuman/daemon/mod.rs +++ b/src-tauri/src/alphahuman/daemon/mod.rs @@ -55,7 +55,9 @@ pub async fn run( let data_dir = config.data_dir.clone(); let cancel_clone = cancel.clone(); handles.push(tokio::spawn(async move { + log::info!("[alphahuman] Starting health event writer task"); spawn_state_writer(app, data_dir, cancel_clone).await; + log::info!("[alphahuman] Health event writer task terminated"); })); } @@ -69,12 +71,13 @@ pub async fn run( initial_backoff, max_backoff ); + log::info!("[alphahuman] health: Events will be emitted every {}s to frontend", STATUS_FLUSH_SECONDS); // Wait for cancellation (Tauri exit) cancel.cancelled().await; crate::alphahuman::health::mark_component_error("daemon", "shutdown requested"); - log::info!("[alphahuman] Daemon supervisor shutting down"); + log::info!("[alphahuman] Daemon supervisor shutting down (health events will stop)"); for handle in &handles { handle.abort(); @@ -195,7 +198,9 @@ pub async fn run_full( Ok(()) } -pub(crate) fn state_file_path(config: &Config) -> PathBuf { + +/// Get the path to the daemon state file shared between internal and external processes. +pub fn state_file_path(config: &Config) -> PathBuf { config .config_path .parent() @@ -322,12 +327,24 @@ async fn spawn_state_writer( let _ = tokio::fs::create_dir_all(parent).await; } + log::info!("[alphahuman] Health state writer starting ({}s intervals)", STATUS_FLUSH_SECONDS); + log::info!("[alphahuman] Health state file: {}", state_path.display()); + let mut interval = tokio::time::interval(Duration::from_secs(STATUS_FLUSH_SECONDS)); + let mut event_count = 0u64; loop { tokio::select! { - _ = interval.tick() => {}, - _ = cancel.cancelled() => break, + _ = interval.tick() => { + event_count += 1; + if event_count % 12 == 1 { // Log every minute (12 * 5s = 60s) + log::info!("[alphahuman] Health monitoring active (event #{})", event_count); + } + }, + _ = cancel.cancelled() => { + log::info!("[alphahuman] Health state writer received shutdown signal"); + break; + } } let mut json = crate::alphahuman::health::snapshot_json(); @@ -336,15 +353,26 @@ async fn spawn_state_writer( "written_at".into(), serde_json::json!(Utc::now().to_rfc3339()), ); + obj.insert( + "event_count".into(), + serde_json::json!(event_count), + ); } // Emit Tauri event for frontend consumption - let _ = app_handle.emit("alphahuman:health", &json); + log::debug!("[alphahuman] Emitting health event #{}: {:?}", event_count, json); + if let Err(e) = app_handle.emit("alphahuman:health", &json) { + log::error!("[alphahuman] Failed to emit health event #{}: {}", event_count, e); + } else { + log::debug!("[alphahuman] Health event #{} emitted successfully", event_count); + } // Also persist to disk let data = serde_json::to_vec_pretty(&json).unwrap_or_else(|_| b"{}".to_vec()); - let _ = tokio::fs::write(&state_path, data).await; + if let Err(e) = tokio::fs::write(&state_path, data).await { + log::debug!("[alphahuman] Failed to write health state to disk: {}", e); + } } } diff --git a/src-tauri/src/alphahuman/service/mod.rs b/src-tauri/src/alphahuman/service/mod.rs index ed97e1690..ddf54470b 100644 --- a/src-tauri/src/alphahuman/service/mod.rs +++ b/src-tauri/src/alphahuman/service/mod.rs @@ -49,20 +49,82 @@ pub fn install(config: &Config) -> Result { pub fn start(config: &Config) -> Result { if cfg!(target_os = "macos") { let plist = macos_service_file()?; - run_checked(Command::new("launchctl").arg("load").arg("-w").arg(&plist))?; - run_checked(Command::new("launchctl").arg("start").arg(SERVICE_LABEL))?; + + // Check if service is already loaded to avoid "Input/output error" + if !is_service_loaded_macos()? { + log::info!("[service] Loading macOS LaunchAgent service"); + run_checked(Command::new("launchctl").arg("load").arg("-w").arg(&plist))?; + } else { + log::info!("[service] LaunchAgent service already loaded, skipping load step"); + } + + // Always try to start - this is safe even if already running + log::info!("[service] Starting macOS LaunchAgent service"); + let start_result = run_checked(Command::new("launchctl").arg("start").arg(SERVICE_LABEL)); + if let Err(e) = start_result { + // Check if it's already running - that's not an error for us + let status_check = status(config)?; + if matches!(status_check.state, ServiceState::Running) { + log::info!("[service] Service was already running - operation successful"); + } else { + return Err(e); + } + } return status(config); } if cfg!(target_os = "linux") { + // Check if service is enabled before trying to start + if !is_service_enabled_linux()? { + log::info!("[service] Enabling systemd service"); + let _ = run_checked(Command::new("systemctl").args(["--user", "enable", "alphahuman.service"])); + } else { + log::info!("[service] Systemd service already enabled"); + } + run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]))?; - run_checked(Command::new("systemctl").args(["--user", "start", "alphahuman.service"]))?; + + // Try to start - systemctl start is idempotent + log::info!("[service] Starting systemd service"); + let start_result = run_checked(Command::new("systemctl").args(["--user", "start", "alphahuman.service"])); + if let Err(e) = start_result { + // Check if it's already active - that's success for us + let status_check = status(config)?; + if matches!(status_check.state, ServiceState::Running) { + log::info!("[service] Service was already running - operation successful"); + } else { + return Err(e); + } + } return status(config); } if cfg!(target_os = "windows") { - let _ = config; - run_checked(Command::new("schtasks").args(["/Run", "/TN", windows_task_name()]))?; + let task_name = windows_task_name(); + + // Check if task exists before trying to run + if !is_task_exists_windows(task_name)? { + log::warn!("[service] Windows scheduled task does not exist, please install first"); + return Ok(ServiceStatus { + state: ServiceState::NotInstalled, + unit_path: None, + label: task_name.to_string(), + details: Some("Task not installed".to_string()), + }); + } + + // Try to run task - this may fail if already running, which is OK + log::info!("[service] Starting Windows scheduled task"); + let run_result = run_checked(Command::new("schtasks").args(["/Run", "/TN", task_name])); + if let Err(e) = run_result { + // Check if it's already running - that's success for us + let status_check = status(config)?; + if matches!(status_check.state, ServiceState::Running) { + log::info!("[service] Task was already running - operation successful"); + } else { + return Err(e); + } + } return status(config); } @@ -263,6 +325,11 @@ fn install_macos(config: &Config) -> Result<()> { {stdout} StandardErrorPath {stderr} + EnvironmentVariables + + ALPHAHUMAN_DAEMON_INTERNAL + false + "#, @@ -391,6 +458,41 @@ fn run_capture(cmd: &mut Command) -> Result { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } +/// Check if the macOS LaunchAgent service is loaded (regardless of running state) +fn is_service_loaded_macos() -> Result { + let out = run_capture(Command::new("launchctl").arg("list"))?; + Ok(out.lines().any(|line| { + line.contains(SERVICE_LABEL) || line.contains(LEGACY_SERVICE_LABEL) + })) +} + +/// Check if the Linux systemd service is enabled +fn is_service_enabled_linux() -> Result { + let result = Command::new("systemctl") + .args(["--user", "is-enabled", "alphahuman.service"]) + .output(); + + match result { + Ok(output) => { + let status_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(status_str == "enabled") + } + Err(_) => Ok(false), // Service not found or other error means not enabled + } +} + +/// Check if the Windows scheduled task exists +fn is_task_exists_windows(task_name: &str) -> Result { + let result = Command::new("schtasks") + .args(["/Query", "/TN", task_name]) + .output(); + + match result { + Ok(output) => Ok(output.status.success()), + Err(_) => Ok(false), // Command failed means task doesn't exist + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/commands/alphahuman.rs b/src-tauri/src/commands/alphahuman.rs index 8a696015c..7d28e22ab 100644 --- a/src-tauri/src/commands/alphahuman.rs +++ b/src-tauri/src/commands/alphahuman.rs @@ -587,7 +587,10 @@ pub async fn alphahuman_service_start() -> Result Result<(), Box> { Ok(()) } +/// Watch daemon health file and bridge changes to frontend Tauri events +async fn watch_daemon_health_file(app_handle: AppHandle, data_dir: PathBuf) { + let state_file = data_dir.join("daemon_state.json"); + let mut interval = interval(Duration::from_secs(2)); + let mut last_modified: Option = None; + + log::info!("[alphahuman] Watching daemon health file: {}", state_file.display()); + + loop { + interval.tick().await; + + // Check if file exists and was modified + if let Ok(metadata) = fs::metadata(&state_file).await { + if let Ok(modified) = metadata.modified() { + if last_modified.map_or(true, |last| modified > last) { + last_modified = Some(modified); + + // Read and parse health data + if let Ok(content) = fs::read_to_string(&state_file).await { + if let Ok(json_value) = serde_json::from_str::(&content) { + log::debug!("[alphahuman] Broadcasting health event from file: {:?}", json_value); + + // Emit Tauri event to frontend (same as internal daemon) + if let Err(e) = app_handle.emit("alphahuman:health", &json_value) { + log::error!("[alphahuman] Failed to emit health event from file: {}", e); + } else { + log::debug!("[alphahuman] Health event emitted successfully from file"); + } + } else { + log::debug!("[alphahuman] Failed to parse health file as JSON: {}", state_file.display()); + } + } else { + log::debug!("[alphahuman] Failed to read health file: {}", state_file.display()); + } + } + } + } else { + // File doesn't exist yet - external daemon may not be writing yet + log::debug!("[alphahuman] Health file not found yet: {}", state_file.display()); + } + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { if let Err(err) = rustls::crypto::ring::default_provider().install_default() { @@ -504,34 +549,23 @@ pub fn run() { }; app.manage(daemon_handle); - if cfg!(target_os = "macos") - && !daemon_mode - && !daemon_foreground_requested() - { - tauri::async_runtime::spawn(async move { - match alphahuman::config::Config::load_or_init().await { - Ok(config) => { - if let Err(e) = alphahuman::service::install(&config) { - log::warn!( - "[alphahuman] LaunchAgent install failed: {e}" - ); - } - if let Err(e) = alphahuman::service::start(&config) { - log::warn!( - "[alphahuman] LaunchAgent start failed: {e}" - ); - } - } - Err(e) => { - log::warn!( - "[alphahuman] Failed to load config for LaunchAgent: {e}" - ); - } - } - }); - } else { + // Determine daemon mode: internal supervisor vs external platform service + let use_internal_daemon = daemon_mode + || daemon_foreground_requested() + || cfg!(debug_assertions) // Always use internal supervisor in debug builds + || std::env::var("ALPHAHUMAN_DAEMON_INTERNAL").unwrap_or("false".to_string()) == "true"; // Cross-platform override via env var + + if use_internal_daemon { + // Run internal daemon supervisor with health event emission + // This path is taken when: + // - Daemon mode enabled, OR + // - Foreground daemon requested, OR + // - Debug build (for easier development), OR + // - ALPHAHUMAN_DAEMON_INTERNAL=true env var (any platform) + log::info!("[alphahuman] Using internal daemon supervisor (ALPHAHUMAN_DAEMON_INTERNAL=true or debug build)"); let app_handle_for_daemon = app.handle().clone(); tauri::async_runtime::spawn(async move { + log::info!("[alphahuman] Starting daemon supervisor with health monitoring"); if let Err(e) = alphahuman::daemon::run( daemon_config, app_handle_for_daemon, @@ -542,6 +576,39 @@ pub fn run() { log::error!("[alphahuman] Daemon supervisor error: {e}"); } }); + } else { + // Start external platform-specific service for background daemon + // This path is taken on all platforms when ALPHAHUMAN_DAEMON_INTERNAL=false/unset + // and not in daemon mode, foreground mode, or debug build + log::info!("[alphahuman] Using external daemon service (ALPHAHUMAN_DAEMON_INTERNAL=false/unset)"); + + // Setup file watching to bridge external daemon health events to frontend + let app_handle_for_watcher = app.handle().clone(); + let data_dir_clone = data_dir.clone(); + tauri::async_runtime::spawn(async move { + watch_daemon_health_file(app_handle_for_watcher, data_dir_clone).await; + }); + + // Start the external platform service + tauri::async_runtime::spawn(async move { + match alphahuman::config::Config::load_or_init().await { + Ok(config) => { + match alphahuman::service::install(&config) { + Ok(status) => log::info!("[alphahuman] External daemon service installed: {:?}", status), + Err(e) => log::error!("[alphahuman] Failed to install external daemon service: {e}"), + } + match alphahuman::service::start(&config) { + Ok(status) => log::info!("[alphahuman] External daemon service started: {:?}", status), + Err(e) => log::error!("[alphahuman] Failed to start external daemon service: {e}"), + } + } + Err(e) => { + log::error!( + "[alphahuman] Failed to load config for external service: {e}" + ); + } + } + }); } } diff --git a/src/components/MiniSidebar.tsx b/src/components/MiniSidebar.tsx index 94399d1f8..45c775bb7 100644 --- a/src/components/MiniSidebar.tsx +++ b/src/components/MiniSidebar.tsx @@ -1,6 +1,10 @@ import { useLocation, useNavigate } from 'react-router-dom'; +import { useState } from 'react'; +import DaemonHealthIndicator from './daemon/DaemonHealthIndicator'; +import DaemonHealthPanel from './daemon/DaemonHealthPanel'; import { useAppSelector } from '../store/hooks'; +import { isTauri } from '../utils/tauriCommands'; const navItems = [ { @@ -90,6 +94,7 @@ const MiniSidebar = () => { const location = useLocation(); const navigate = useNavigate(); const token = useAppSelector(state => state.auth.token); + const [showDaemonPanel, setShowDaemonPanel] = useState(false); // Unread count for Conversations: threads with lastMessageAt > lastViewedAt (must be before early return) const conversationsUnreadCount = useAppSelector(state => { @@ -115,37 +120,69 @@ const MiniSidebar = () => { }; return ( -
- {navItems.map(item => { - const active = isActive(item.path); - const showUnreadBadge = item.id === 'conversations' && conversationsUnreadCount > 0; - return ( -
- - {showUnreadBadge && ( - - {conversationsUnreadCount > 99 ? '99+' : conversationsUnreadCount} - - )} - {/* Tooltip - appears to the right */} + <> +
+ {/* Navigation Items */} +
+ {navItems.map(item => { + const active = isActive(item.path); + const showUnreadBadge = item.id === 'conversations' && conversationsUnreadCount > 0; + return ( +
+ + {showUnreadBadge && ( + + {conversationsUnreadCount > 99 ? '99+' : conversationsUnreadCount} + + )} + {/* Tooltip - appears to the right */} +
+ {item.label} +
+
+ ); + })} +
+ + {/* Daemon Health Indicator - Only show in Tauri mode */} + {isTauri() && ( +
+
+ setShowDaemonPanel(true)} + /> +
+ {/* Tooltip */}
- {item.label} + Daemon Status
- ); - })} -
+ )} +
+ + {/* Daemon Health Panel Modal */} + {showDaemonPanel && ( +
+
+ setShowDaemonPanel(false)} + /> +
+
+ )} + ); }; diff --git a/src/components/daemon/DaemonHealthIndicator.tsx b/src/components/daemon/DaemonHealthIndicator.tsx new file mode 100644 index 000000000..162b08806 --- /dev/null +++ b/src/components/daemon/DaemonHealthIndicator.tsx @@ -0,0 +1,125 @@ +/** + * Daemon Health Indicator + * + * Compact status indicator showing daemon health with a colored dot and optional label. + * Can be clicked to show detailed health information. + */ +import type React from 'react'; + +import { useDaemonHealth, formatRelativeTime } from '../../hooks/useDaemonHealth'; +import type { DaemonStatus } from '../../store/daemonSlice'; + +interface Props { + userId?: string; + size?: 'sm' | 'md' | 'lg'; + showLabel?: boolean; + onClick?: () => void; + className?: string; +} + +const DaemonHealthIndicator: React.FC = ({ + userId, + size = 'md', + showLabel = false, + onClick, + className = '', +}) => { + const daemonHealth = useDaemonHealth(userId); + + // Size configurations + const sizeConfig = { + sm: { + dot: 'w-2 h-2', + text: 'text-xs', + container: 'gap-1.5', + }, + md: { + dot: 'w-3 h-3', + text: 'text-sm', + container: 'gap-2', + }, + lg: { + dot: 'w-4 h-4', + text: 'text-base', + container: 'gap-2.5', + }, + }; + + const config = sizeConfig[size]; + + // Status color mapping + const getStatusColor = (status: DaemonStatus): string => { + switch (status) { + case 'running': + return 'bg-green-500'; + case 'starting': + return 'bg-yellow-500'; + case 'error': + return 'bg-red-500'; + case 'disconnected': + default: + return 'bg-gray-500'; + } + }; + + // Status text mapping + const getStatusText = (status: DaemonStatus): string => { + switch (status) { + case 'running': + return 'Running'; + case 'starting': + return 'Starting'; + case 'error': + return 'Error'; + case 'disconnected': + default: + return 'Disconnected'; + } + }; + + // Tooltip content + const getTooltipContent = (): string => { + const { status, componentCount, healthyComponentCount, errorComponentCount, lastUpdate } = daemonHealth; + + let tooltip = `Status: ${getStatusText(status)}`; + + if (componentCount > 0) { + tooltip += `\nComponents: ${healthyComponentCount}/${componentCount} healthy`; + if (errorComponentCount > 0) { + tooltip += ` (${errorComponentCount} errors)`; + } + } + + if (lastUpdate) { + tooltip += `\nLast update: ${formatRelativeTime(lastUpdate)}`; + } + + return tooltip; + }; + + const statusColor = getStatusColor(daemonHealth.status); + const statusText = getStatusText(daemonHealth.status); + + const containerClasses = ` + flex items-center ${config.container} + ${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''} + ${className} + `.trim(); + + return ( +
+
+ {showLabel && ( + + {statusText} + + )} +
+ ); +}; + +export default DaemonHealthIndicator; \ No newline at end of file diff --git a/src/components/daemon/DaemonHealthPanel.tsx b/src/components/daemon/DaemonHealthPanel.tsx new file mode 100644 index 000000000..df77b000f --- /dev/null +++ b/src/components/daemon/DaemonHealthPanel.tsx @@ -0,0 +1,283 @@ +/** + * Daemon Health Panel + * + * Detailed health breakdown component showing daemon status, component health, + * and providing manual control buttons for daemon lifecycle management. + */ +import { useState } from 'react'; +import { + CheckCircleIcon, + XCircleIcon, + ClockIcon, + ArrowPathIcon, + PlayIcon, + StopIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; + +import { useDaemonHealth, formatRelativeTime } from '../../hooks/useDaemonHealth'; +import type { DaemonStatus, ComponentHealth } from '../../store/daemonSlice'; + +interface Props { + userId?: string; + onClose?: () => void; + className?: string; +} + +const DaemonHealthPanel = ({ userId, onClose, className = '' }: Props) => { + const daemonHealth = useDaemonHealth(userId); + const [operationLoading, setOperationLoading] = useState(null); + + // Handle daemon operations with loading states + const handleOperation = async (operation: () => Promise, operationName: string) => { + setOperationLoading(operationName); + try { + await operation(); + } catch (error) { + console.error(`[DaemonHealthPanel] ${operationName} failed:`, error); + } finally { + setOperationLoading(null); + } + }; + + // Status styling + const getStatusStyling = (status: DaemonStatus) => { + switch (status) { + case 'running': + return { + bg: 'bg-green-900/20 border-green-500/30', + text: 'text-green-400', + icon: CheckCircleIcon, + }; + case 'starting': + return { + bg: 'bg-yellow-900/20 border-yellow-500/30', + text: 'text-yellow-400', + icon: ClockIcon, + }; + case 'error': + return { + bg: 'bg-red-900/20 border-red-500/30', + text: 'text-red-400', + icon: XCircleIcon, + }; + case 'disconnected': + default: + return { + bg: 'bg-gray-900/20 border-gray-500/30', + text: 'text-gray-400', + icon: XCircleIcon, + }; + } + }; + + // Component status styling + const getComponentStyling = (component: ComponentHealth) => { + switch (component.status) { + case 'ok': + return { + bg: 'bg-green-500', + text: 'text-green-400', + icon: CheckCircleIcon, + }; + case 'error': + return { + bg: 'bg-red-500', + text: 'text-red-400', + icon: XCircleIcon, + }; + case 'starting': + return { + bg: 'bg-yellow-500', + text: 'text-yellow-400', + icon: ClockIcon, + }; + } + }; + + const statusStyling = getStatusStyling(daemonHealth.status); + const StatusIcon = statusStyling.icon; + + return ( +
+ {/* Header */} +
+

Daemon Health

+ {onClose && ( + + )} +
+ + {/* Overall Status */} +
+
+
+ +
+
+ Status: {daemonHealth.status.charAt(0).toUpperCase() + daemonHealth.status.slice(1)} +
+ {daemonHealth.healthSnapshot && ( +
+ PID: {daemonHealth.healthSnapshot.pid} • Uptime: {daemonHealth.uptimeText} +
+ )} + {daemonHealth.lastUpdate && ( +
+ Last update: {formatRelativeTime(daemonHealth.lastUpdate)} +
+ )} +
+
+ + {/* Recovery indicator */} + {daemonHealth.isRecovering && ( +
+ + Recovering... +
+ )} +
+
+ + {/* Component Health */} + {daemonHealth.componentCount > 0 && ( +
+

Components ({daemonHealth.healthyComponentCount}/{daemonHealth.componentCount} healthy)

+
+ {Object.entries(daemonHealth.components).map(([name, component]) => { + const componentStyling = getComponentStyling(component); + const ComponentIcon = componentStyling.icon; + + return ( +
+
+
+
+ + {name} +
+
+ Updated: {formatRelativeTime(component.updated_at)} +
+ {component.restart_count > 0 && ( +
+ Restarts: {component.restart_count} +
+ )} + {component.last_error && component.status === 'error' && ( +
+ Error: {component.last_error} +
+ )} +
+
+ ); + })} +
+
+ )} + + {/* Auto-start Toggle */} +
+
+
Auto-start Daemon
+
Automatically start daemon on app launch
+
+ +
+ + {/* Control Actions */} +
+ + + + + + + +
+ + {/* Connection Info */} + {daemonHealth.connectionAttempts > 0 && ( +
+
+ Connection attempts: {daemonHealth.connectionAttempts} +
+
+ )} + + {/* Debug Info (development only) */} + {process.env.NODE_ENV === 'development' && daemonHealth.healthSnapshot && ( +
+ Debug Info +
+            {JSON.stringify(daemonHealth.healthSnapshot, null, 2)}
+          
+
+ )} +
+ ); +}; + +export default DaemonHealthPanel; \ No newline at end of file diff --git a/src/components/settings/panels/TauriCommandsPanel.tsx b/src/components/settings/panels/TauriCommandsPanel.tsx index e57b11691..9719c495f 100644 --- a/src/components/settings/panels/TauriCommandsPanel.tsx +++ b/src/components/settings/panels/TauriCommandsPanel.tsx @@ -14,6 +14,8 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; import SectionCard from './components/SectionCard'; import InputGroup, { Field, CheckboxField } from './components/InputGroup'; import ActionPanel, { PrimaryButton } from './components/ActionPanel'; +import DaemonHealthIndicator from '../../daemon/DaemonHealthIndicator'; +import { useDaemonHealth, formatRelativeTime } from '../../../hooks/useDaemonHealth'; import { alphahumanAgentChat, alphahumanDecryptSecret, @@ -32,9 +34,7 @@ import { alphahumanUpdateRuntimeSettings, alphahumanUpdateTunnelSettings, alphahumanServiceInstall, - alphahumanServiceStart, alphahumanServiceStatus, - alphahumanServiceStop, alphahumanServiceUninstall, alphahumanEncryptSecret, isTauri, @@ -50,6 +50,7 @@ const formatJson = (value: unknown) => JSON.stringify(value, null, 2); const TauriCommandsPanel = () => { const { navigateBack } = useSettingsNavigation(); + const daemonHealth = useDaemonHealth(); // View mode removed - always show all sections const [expandedSections] = useState>( @@ -527,44 +528,120 @@ const TauriCommandsPanel = () => {
)} - +
- - run(alphahumanServiceStatus, 'serviceStatus')} - loading={operationLoading === 'serviceStatus'} - > - Status - - run(alphahumanServiceInstall, 'serviceInstall')} - loading={operationLoading === 'serviceInstall'} - variant="outline" - > - Install - - run(alphahumanServiceStart, 'serviceStart')} - loading={operationLoading === 'serviceStart'} - variant="outline" - > - Start - - run(alphahumanServiceStop, 'serviceStop')} - loading={operationLoading === 'serviceStop'} - variant="outline" - > - Stop - - run(alphahumanServiceUninstall, 'serviceUninstall')} - loading={operationLoading === 'serviceUninstall'} - variant="outline" - > - Uninstall - - +
+ {/* Live Status Display */} +
+
+ +
+
Daemon Status: {daemonHealth.status}
+
+ Last update: {daemonHealth.lastUpdate ? formatRelativeTime(daemonHealth.lastUpdate) : 'Never'} +
+ {daemonHealth.healthSnapshot && ( +
+ PID: {daemonHealth.healthSnapshot.pid} • Uptime: {daemonHealth.uptimeText} +
+ )} +
+
+ {daemonHealth.status === 'error' && ( + daemonHealth.restartDaemon()} + variant="outline" + loading={daemonHealth.isRecovering} + > + Restart + + )} +
+ + {/* Component Health */} + {daemonHealth.componentCount > 0 && ( +
+ {Object.entries(daemonHealth.components).map(([name, health]) => ( +
+
+ {name} + {health.restart_count > 0 && ( + ({health.restart_count}) + )} +
+ ))} +
+ )} + + {/* Service Controls */} + + daemonHealth.startDaemon()} + loading={operationLoading === 'serviceStart'} + disabled={daemonHealth.status === 'running'} + > + Start + + daemonHealth.stopDaemon()} + loading={operationLoading === 'serviceStop'} + disabled={daemonHealth.status === 'disconnected'} + variant="outline" + > + Stop + + run(alphahumanServiceStatus, 'serviceStatus')} + loading={operationLoading === 'serviceStatus'} + variant="outline" + > + Status + + run(alphahumanServiceInstall, 'serviceInstall')} + loading={operationLoading === 'serviceInstall'} + variant="outline" + > + Install + + run(alphahumanServiceUninstall, 'serviceUninstall')} + loading={operationLoading === 'serviceUninstall'} + variant="outline" + > + Uninstall + + + + {/* Auto-start Toggle */} +
+
+
Auto-start Daemon
+
Automatically start daemon on app launch
+
+ +
+ + {/* Connection Info */} + {daemonHealth.connectionAttempts > 0 && ( +
+
+ Connection attempts: {daemonHealth.connectionAttempts} +
+
+ )} +
diff --git a/src/hooks/useDaemonHealth.ts b/src/hooks/useDaemonHealth.ts new file mode 100644 index 000000000..e6e95a492 --- /dev/null +++ b/src/hooks/useDaemonHealth.ts @@ -0,0 +1,198 @@ +/** + * Daemon Health Hook + * + * React hook for accessing daemon health state and actions. + * Provides convenient access to daemon status, components, and control functions. + */ +import { useCallback } from 'react'; + +import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + resetConnectionAttempts, + selectDaemonComponents, + selectDaemonConnectionAttempts, + selectDaemonHealthSnapshot, + selectDaemonLastHealthUpdate, + selectDaemonStatus, + selectIsDaemonAutoStartEnabled, + selectIsDaemonRecovering, + setAutoStartEnabled, + setIsRecovering, +} from '../store/daemonSlice'; +import { + alphahumanServiceStart, + alphahumanServiceStop, + alphahumanServiceStatus, + type CommandResponse, + type ServiceStatus, +} from '../utils/tauriCommands'; + +export const useDaemonHealth = (userId?: string) => { + const dispatch = useAppDispatch(); + + // Selectors + const status = useAppSelector(state => selectDaemonStatus(state, userId)); + const components = useAppSelector(state => selectDaemonComponents(state, userId)); + const healthSnapshot = useAppSelector(state => selectDaemonHealthSnapshot(state, userId)); + const lastUpdate = useAppSelector(state => selectDaemonLastHealthUpdate(state, userId)); + const isAutoStartEnabled = useAppSelector(state => selectIsDaemonAutoStartEnabled(state, userId)); + const connectionAttempts = useAppSelector(state => selectDaemonConnectionAttempts(state, userId)); + const isRecovering = useAppSelector(state => selectIsDaemonRecovering(state, userId)); + + // Action creators + const startDaemon = useCallback(async (): Promise | null> => { + try { + const result = await alphahumanServiceStart(); + // Check if the service status indicates success + if (result.result && result.result.state === 'Running') { + dispatch(resetConnectionAttempts({ userId: userId || '__pending__' })); + } + return result; + } catch (error) { + console.error('[useDaemonHealth] Failed to start daemon:', error); + return null; + } + }, [dispatch, userId]); + + const stopDaemon = useCallback(async (): Promise | null> => { + try { + return await alphahumanServiceStop(); + } catch (error) { + console.error('[useDaemonHealth] Failed to stop daemon:', error); + return null; + } + }, []); + + const restartDaemon = useCallback(async (): Promise => { + const uid = userId || '__pending__'; + try { + dispatch(setIsRecovering({ userId: uid, isRecovering: true })); + + // Stop first + const stopResult = await alphahumanServiceStop(); + if (!stopResult?.result || stopResult.result.state !== 'Stopped') { + console.warn('[useDaemonHealth] Stop daemon failed, but continuing with start'); + } + + // Wait a moment for clean shutdown + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Start again + const startResult = await alphahumanServiceStart(); + const success = startResult?.result && startResult.result.state === 'Running'; + + if (success) { + dispatch(resetConnectionAttempts({ userId: uid })); + } + + dispatch(setIsRecovering({ userId: uid, isRecovering: false })); + return success; + } catch (error) { + console.error('[useDaemonHealth] Failed to restart daemon:', error); + dispatch(setIsRecovering({ userId: uid, isRecovering: false })); + return false; + } + }, [dispatch, userId]); + + const checkDaemonStatus = useCallback(async (): Promise | null> => { + try { + return await alphahumanServiceStatus(); + } catch (error) { + console.error('[useDaemonHealth] Failed to check daemon status:', error); + return null; + } + }, []); + + const setAutoStart = useCallback( + (enabled: boolean) => { + dispatch(setAutoStartEnabled({ userId: userId || '__pending__', enabled })); + }, + [dispatch, userId] + ); + + // Derived state + const isHealthy = status === 'running'; + const hasErrors = status === 'error'; + const isConnected = status !== 'disconnected'; + const isStarting = status === 'starting'; + + const componentCount = Object.keys(components).length; + const healthyComponentCount = Object.values(components).filter(c => c.status === 'ok').length; + const errorComponentCount = Object.values(components).filter(c => c.status === 'error').length; + + // Get uptime in human readable format + const uptimeText = healthSnapshot + ? formatUptime(healthSnapshot.uptime_seconds) + : 'Unknown'; + + return { + // State + status, + components, + healthSnapshot, + lastUpdate, + isAutoStartEnabled, + connectionAttempts, + isRecovering, + + // Derived state + isHealthy, + hasErrors, + isConnected, + isStarting, + componentCount, + healthyComponentCount, + errorComponentCount, + uptimeText, + + // Actions + startDaemon, + stopDaemon, + restartDaemon, + checkDaemonStatus, + setAutoStart, + }; +}; + +/** + * Format uptime seconds into human-readable string + */ +function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (days > 0) { + return `${days}d ${hours}h ${minutes}m`; + } else if (hours > 0) { + return `${hours}h ${minutes}m ${secs}s`; + } else if (minutes > 0) { + return `${minutes}m ${secs}s`; + } else { + return `${secs}s`; + } +} + +/** + * Format relative time from ISO string + */ +export function formatRelativeTime(isoString: string): string { + const date = new Date(isoString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + + if (diffSeconds < 60) { + return `${diffSeconds}s ago`; + } else if (diffSeconds < 3600) { + const minutes = Math.floor(diffSeconds / 60); + return `${minutes}m ago`; + } else if (diffSeconds < 86400) { + const hours = Math.floor(diffSeconds / 3600); + return `${hours}h ago`; + } else { + const days = Math.floor(diffSeconds / 86400); + return `${days}d ago`; + } +} \ No newline at end of file diff --git a/src/hooks/useDaemonLifecycle.ts b/src/hooks/useDaemonLifecycle.ts new file mode 100644 index 000000000..454ce31d1 --- /dev/null +++ b/src/hooks/useDaemonLifecycle.ts @@ -0,0 +1,266 @@ +/** + * Daemon Lifecycle Management Hook + * + * Handles automatic daemon lifecycle management including: + * - Auto-start on app launch (if enabled) + * - Background/foreground event handling + * - Exponential backoff for restart attempts + * - Error recovery logic + */ +import { useEffect, useRef, useCallback } from 'react'; + +import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + incrementConnectionAttempts, + resetConnectionAttempts, + selectDaemonStatus, + selectIsDaemonAutoStartEnabled, + selectDaemonConnectionAttempts, + selectIsDaemonRecovering, + setIsRecovering, +} from '../store/daemonSlice'; +import { useDaemonHealth } from './useDaemonHealth'; +import { isTauri } from '../utils/tauriCommands'; + +// Configuration constants +const MAX_RECONNECTION_ATTEMPTS = 5; +const BASE_RETRY_DELAY_MS = 1000; // 1 second +const MAX_RETRY_DELAY_MS = 30000; // 30 seconds +const AUTO_START_DELAY_MS = 3000; // 3 seconds after app start + +export const useDaemonLifecycle = (userId?: string) => { + const dispatch = useAppDispatch(); + const daemonHealth = useDaemonHealth(userId); + + // Selectors + const status = useAppSelector(state => selectDaemonStatus(state, userId)); + const isAutoStartEnabled = useAppSelector(state => selectIsDaemonAutoStartEnabled(state, userId)); + const connectionAttempts = useAppSelector(state => selectDaemonConnectionAttempts(state, userId)); + const isRecovering = useAppSelector(state => selectIsDaemonRecovering(state, userId)); + + // Refs for cleanup + const autoStartTimeoutRef = useRef | null>(null); + const retryTimeoutRef = useRef | null>(null); + const isMountedRef = useRef(true); + + // Calculate exponential backoff delay + const calculateRetryDelay = useCallback((attempt: number): number => { + const exponentialDelay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt - 1); + return Math.min(exponentialDelay, MAX_RETRY_DELAY_MS); + }, []); + + // Auto-start daemon if enabled and conditions are met + const attemptAutoStart = useCallback(async () => { + if (!isTauri() || !isAutoStartEnabled || !isMountedRef.current) { + return; + } + + // Only auto-start if daemon is disconnected and not already recovering + if (status === 'disconnected' && !isRecovering && connectionAttempts === 0) { + console.log('[DaemonLifecycle] Attempting auto-start of daemon'); + + try { + dispatch(setIsRecovering({ userId: userId || '__pending__', isRecovering: true })); + const result = await daemonHealth.startDaemon(); + + if (result?.result && result.result.state === 'Running') { + console.log('[DaemonLifecycle] Auto-start successful'); + dispatch(resetConnectionAttempts({ userId: userId || '__pending__' })); + } else { + console.warn('[DaemonLifecycle] Auto-start failed:', result); + dispatch(incrementConnectionAttempts({ userId: userId || '__pending__' })); + } + } catch (error) { + console.error('[DaemonLifecycle] Auto-start error:', error); + dispatch(incrementConnectionAttempts({ userId: userId || '__pending__' })); + } finally { + dispatch(setIsRecovering({ userId: userId || '__pending__', isRecovering: false })); + } + } + }, [ + isAutoStartEnabled, + status, + isRecovering, + connectionAttempts, + userId, + dispatch, + daemonHealth, + ]); + + // Retry connection with exponential backoff + const scheduleRetry = useCallback(() => { + if (!isTauri() || !isMountedRef.current || isRecovering) { + return; + } + + // Don't retry if we've exceeded max attempts + if (connectionAttempts >= MAX_RECONNECTION_ATTEMPTS) { + console.warn('[DaemonLifecycle] Max reconnection attempts reached'); + return; + } + + // Don't retry if daemon is already running or starting + if (status === 'running' || status === 'starting') { + return; + } + + const retryDelay = calculateRetryDelay(connectionAttempts + 1); + console.log(`[DaemonLifecycle] Scheduling retry attempt ${connectionAttempts + 1} in ${retryDelay}ms`); + + // Clear existing timeout + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + } + + retryTimeoutRef.current = setTimeout(async () => { + if (!isMountedRef.current) return; + + try { + dispatch(setIsRecovering({ userId: userId || '__pending__', isRecovering: true })); + dispatch(incrementConnectionAttempts({ userId: userId || '__pending__' })); + + const result = await daemonHealth.startDaemon(); + + if (result?.result && result.result.state === 'Running') { + console.log('[DaemonLifecycle] Retry successful'); + dispatch(resetConnectionAttempts({ userId: userId || '__pending__' })); + } else { + console.warn('[DaemonLifecycle] Retry failed:', result); + // Will trigger another retry via useEffect + } + } catch (error) { + console.error('[DaemonLifecycle] Retry error:', error); + // Will trigger another retry via useEffect + } finally { + dispatch(setIsRecovering({ userId: userId || '__pending__', isRecovering: false })); + } + }, retryDelay); + }, [ + connectionAttempts, + status, + isRecovering, + calculateRetryDelay, + userId, + dispatch, + daemonHealth, + ]); + + // Handle visibility change (background/foreground) + const handleVisibilityChange = useCallback(() => { + if (!isTauri() || !isMountedRef.current) return; + + if (document.visibilityState === 'visible') { + console.log('[DaemonLifecycle] App became visible - checking daemon status'); + + // Check if daemon needs to be started when app comes back to foreground + if (isAutoStartEnabled && status === 'disconnected' && !isRecovering) { + // Small delay to allow app to fully activate + setTimeout(() => { + if (isMountedRef.current) { + attemptAutoStart(); + } + }, 1000); + } + } + }, [isAutoStartEnabled, status, isRecovering, attemptAutoStart]); + + // Main lifecycle effect + useEffect(() => { + if (!isTauri()) return; + + console.log('[DaemonLifecycle] Setting up daemon lifecycle management'); + + // Setup auto-start with delay on mount + if (isAutoStartEnabled) { + autoStartTimeoutRef.current = setTimeout(() => { + if (isMountedRef.current) { + attemptAutoStart(); + } + }, AUTO_START_DELAY_MS); + } + + // Setup visibility change listener + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + console.log('[DaemonLifecycle] Cleaning up daemon lifecycle management'); + isMountedRef.current = false; + + // Clear timeouts + if (autoStartTimeoutRef.current) { + clearTimeout(autoStartTimeoutRef.current); + autoStartTimeoutRef.current = null; + } + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + + // Remove event listeners + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [isAutoStartEnabled, attemptAutoStart, handleVisibilityChange]); + + // Retry effect - triggers when daemon goes into error state or connection fails + useEffect(() => { + if (!isTauri() || !isMountedRef.current) return; + + // Schedule retry if daemon is in error state or disconnected with failed attempts + if ( + (status === 'error' || status === 'disconnected') && + connectionAttempts > 0 && + connectionAttempts < MAX_RECONNECTION_ATTEMPTS && + !isRecovering && + isAutoStartEnabled + ) { + console.log('[DaemonLifecycle] Scheduling retry for daemon recovery'); + scheduleRetry(); + } + + return () => { + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + }; + }, [status, connectionAttempts, isRecovering, isAutoStartEnabled, scheduleRetry]); + + // Reset connection attempts when daemon becomes healthy + useEffect(() => { + if (status === 'running' && connectionAttempts > 0) { + console.log('[DaemonLifecycle] Daemon healthy - resetting connection attempts'); + dispatch(resetConnectionAttempts({ userId: userId || '__pending__' })); + + // Clear retry timeout if running + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + } + }, [status, connectionAttempts, userId, dispatch]); + + // Return lifecycle state and controls + return { + // State + isAutoStartEnabled, + connectionAttempts, + isRecovering, + maxAttemptsReached: connectionAttempts >= MAX_RECONNECTION_ATTEMPTS, + + // Actions + attemptAutoStart, + resetRetries: () => { + dispatch(resetConnectionAttempts({ userId: userId || '__pending__' })); + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + }, + + // Config + MAX_RECONNECTION_ATTEMPTS, + nextRetryDelay: connectionAttempts < MAX_RECONNECTION_ATTEMPTS + ? calculateRetryDelay(connectionAttempts + 1) + : null, + }; +}; \ No newline at end of file diff --git a/src/providers/SocketProvider.tsx b/src/providers/SocketProvider.tsx index 5c8be4ce5..dfbe2edf8 100644 --- a/src/providers/SocketProvider.tsx +++ b/src/providers/SocketProvider.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; +import { useDaemonLifecycle } from '../hooks/useDaemonLifecycle'; import { socketService } from '../services/socketService'; import { store } from '../store'; import { useAppSelector } from '../store/hooks'; @@ -25,21 +26,71 @@ import { * In web mode: uses the frontend Socket.io client directly. */ const SocketProvider = ({ children }: { children: React.ReactNode }) => { + console.log('[SocketProvider] Component mounting/re-rendering'); const token = useAppSelector(state => state.auth.token); const socketStatus = useAppSelector(selectSocketStatus); const previousTokenRef = useRef(null); const tauriListenersSetup = useRef(false); const usesRustSocket = isTauri(); + console.log('[SocketProvider] usesRustSocket:', usesRustSocket, 'isTauri():', isTauri()); + + // Setup daemon lifecycle management in Tauri mode + const daemonLifecycle = useDaemonLifecycle(); + + // Log daemon lifecycle state for debugging + useEffect(() => { + if (usesRustSocket && process.env.NODE_ENV === 'development') { + console.log('[SocketProvider] Daemon lifecycle state:', { + isAutoStartEnabled: daemonLifecycle.isAutoStartEnabled, + connectionAttempts: daemonLifecycle.connectionAttempts, + isRecovering: daemonLifecycle.isRecovering, + maxAttemptsReached: daemonLifecycle.maxAttemptsReached, + }); + } + }, [ + usesRustSocket, + daemonLifecycle.isAutoStartEnabled, + daemonLifecycle.connectionAttempts, + daemonLifecycle.isRecovering, + daemonLifecycle.maxAttemptsReached, + ]); // Setup Tauri event listeners once useEffect(() => { + console.log('[SocketProvider] useEffect triggered, usesRustSocket:', usesRustSocket, 'tauriListenersSetup:', tauriListenersSetup.current); + if (usesRustSocket && !tauriListenersSetup.current) { - setupTauriSocketListeners(); + console.log('[SocketProvider] Condition met - calling setupTauriSocketListeners()'); + console.log('[SocketProvider] About to call setupTauriSocketListeners()'); + + // Set this immediately to prevent multiple calls tauriListenersSetup.current = true; + + setupTauriSocketListeners() + .then(() => { + console.log('[SocketProvider] setupTauriSocketListeners() completed successfully'); + }) + .catch((error) => { + console.error('[SocketProvider] setupTauriSocketListeners() failed:', error); + console.error('[SocketProvider] Error details:', { + message: error?.message, + stack: error?.stack, + toString: error?.toString(), + }); + // Reset flag on failure so it can retry + tauriListenersSetup.current = false; + }); + } else if (usesRustSocket && tauriListenersSetup.current) { + console.log('[SocketProvider] Tauri listeners already set up, skipping'); + } else if (!usesRustSocket) { + console.log('[SocketProvider] Not using Rust socket, skipping Tauri listener setup'); + } else { + console.log('[SocketProvider] Unexpected condition - usesRustSocket:', usesRustSocket, 'tauriListenersSetup.current:', tauriListenersSetup.current); } return () => { if (usesRustSocket && tauriListenersSetup.current) { + console.log('[SocketProvider] Cleaning up Tauri socket listeners'); cleanupTauriSocketListeners(); tauriListenersSetup.current = false; } diff --git a/src/services/api/userApi.ts b/src/services/api/userApi.ts index 5685c14f4..419b182d0 100644 --- a/src/services/api/userApi.ts +++ b/src/services/api/userApi.ts @@ -19,11 +19,11 @@ export const userApi = { /** * Mark onboarding complete for the current user. - * POST /telegram/settings/onboarding-complete + * POST /settings/onboarding-complete */ onboardingComplete: async (): Promise => { await apiClient.post<{ success: boolean; data: unknown }>( - '/telegram/settings/onboarding-complete', + '/settings/onboarding-complete', {} ); }, diff --git a/src/services/daemonHealthService.ts b/src/services/daemonHealthService.ts new file mode 100644 index 000000000..e619c0d26 --- /dev/null +++ b/src/services/daemonHealthService.ts @@ -0,0 +1,207 @@ +/** + * Daemon Health Service + * + * Manages health monitoring for the alphahuman daemon by listening to + * 'alphahuman:health' events emitted by the Rust backend every 5 seconds. + */ +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +import { store } from '../store'; +import { + type ComponentHealth, + type HealthSnapshot, + setDaemonStatus, + setHealthTimeoutId, + updateHealthSnapshot, +} from '../store/daemonSlice'; + +export class DaemonHealthService { + private healthTimeoutId: ReturnType | null = null; + private readonly HEALTH_TIMEOUT_MS = 30000; // 30 seconds + private healthEventListener: UnlistenFn | null = null; + + /** + * Setup health event listener from the Rust daemon. + * Should be called once when the app starts in Tauri mode. + */ + async setupHealthListener(): Promise { + console.log('[DaemonHealth] setupHealthListener() called - starting setup process'); + try { + console.log('[DaemonHealth] About to call listen() for alphahuman:health event'); + console.log('[DaemonHealth] Setting up alphahuman:health event listener'); + + this.healthEventListener = await listen('alphahuman:health', event => { + console.log('[DaemonHealth] Received health event:', event.payload); + + const healthSnapshot = this.parseHealthSnapshot(event.payload); + if (healthSnapshot) { + this.updateReduxFromHealth(healthSnapshot); + this.startHealthTimeout(); + } else { + console.warn('[DaemonHealth] Failed to parse health snapshot:', event.payload); + } + }); + console.log('[DaemonHealth] alphahuman:health listener created successfully'); + + // Start initial timeout + console.log('[DaemonHealth] Starting health timeout'); + this.startHealthTimeout(); + console.log('[DaemonHealth] Health timeout started'); + + console.log('[DaemonHealth] Health listener setup complete'); + return this.healthEventListener; + } catch (error) { + console.error('[DaemonHealth] Failed to setup health listener:', error); + return null; + } + } + + /** + * Cleanup the health event listener. + */ + cleanup(): void { + if (this.healthEventListener) { + this.healthEventListener(); + this.healthEventListener = null; + } + + if (this.healthTimeoutId) { + clearTimeout(this.healthTimeoutId); + this.healthTimeoutId = null; + } + } + + /** + * Parse the health snapshot received from Rust. + */ + private parseHealthSnapshot(payload: unknown): HealthSnapshot | null { + try { + if (!payload || typeof payload !== 'object') { + return null; + } + + const data = payload as Record; + + // Validate required fields + if ( + typeof data.pid !== 'number' || + typeof data.updated_at !== 'string' || + typeof data.uptime_seconds !== 'number' || + !data.components || + typeof data.components !== 'object' + ) { + return null; + } + + // Parse components + const components: Record = {}; + const componentsData = data.components as Record; + + for (const [name, component] of Object.entries(componentsData)) { + if (!component || typeof component !== 'object') { + continue; + } + + const comp = component as Record; + if ( + typeof comp.status !== 'string' || + typeof comp.updated_at !== 'string' || + typeof comp.restart_count !== 'number' + ) { + continue; + } + + // Validate status is a valid ComponentStatus + if (comp.status !== 'ok' && comp.status !== 'error' && comp.status !== 'starting') { + continue; + } + + components[name] = { + status: comp.status as 'ok' | 'error' | 'starting', + updated_at: comp.updated_at, + last_ok: typeof comp.last_ok === 'string' ? comp.last_ok : undefined, + last_error: typeof comp.last_error === 'string' ? comp.last_error : undefined, + restart_count: comp.restart_count, + }; + } + + return { + pid: data.pid as number, + updated_at: data.updated_at as string, + uptime_seconds: data.uptime_seconds as number, + components, + }; + } catch (error) { + console.error('[DaemonHealth] Error parsing health snapshot:', error); + return null; + } + } + + /** + * Update Redux state based on received health snapshot. + */ + private updateReduxFromHealth(snapshot: HealthSnapshot): void { + const userId = this.getUserId(); + + try { + // Update the health snapshot in Redux + store.dispatch(updateHealthSnapshot({ userId, healthSnapshot: snapshot })); + + console.log('[DaemonHealth] Updated health snapshot for user:', userId, snapshot); + } catch (error) { + console.error('[DaemonHealth] Error updating Redux from health:', error); + } + } + + /** + * Start or restart the health timeout. + * If no health events are received within the timeout period, + * the daemon status will be set to 'disconnected'. + */ + private startHealthTimeout(): void { + // Clear existing timeout + if (this.healthTimeoutId) { + clearTimeout(this.healthTimeoutId); + } + + const userId = this.getUserId(); + + // Set new timeout + this.healthTimeoutId = setTimeout(() => { + console.warn('[DaemonHealth] Health timeout reached - setting status to disconnected'); + store.dispatch(setDaemonStatus({ userId, status: 'disconnected' })); + store.dispatch(setHealthTimeoutId({ userId, timeoutId: null })); + this.healthTimeoutId = null; + }, this.HEALTH_TIMEOUT_MS); + + // Store timeout ID in Redux for cleanup + store.dispatch( + setHealthTimeoutId({ + userId, + timeoutId: this.healthTimeoutId.toString(), + }) + ); + } + + /** + * Get the current user ID for daemon state management. + */ + private getUserId(): string { + const token = store.getState().auth.token; + if (!token) return '__pending__'; + + try { + const parts = token.split('.'); + if (parts.length !== 3) return '__pending__'; + const payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const payloadJson = atob(payloadBase64); + const payload = JSON.parse(payloadJson); + return payload.tgUserId || payload.userId || payload.sub || '__pending__'; + } catch { + return '__pending__'; + } + } +} + +// Export singleton instance +export const daemonHealthService = new DaemonHealthService(); \ No newline at end of file diff --git a/src/store/daemonSlice.ts b/src/store/daemonSlice.ts new file mode 100644 index 000000000..2b409dab6 --- /dev/null +++ b/src/store/daemonSlice.ts @@ -0,0 +1,214 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +export type DaemonStatus = 'starting' | 'running' | 'error' | 'disconnected'; +export type ComponentStatus = 'ok' | 'error' | 'starting'; + +export interface ComponentHealth { + status: ComponentStatus; + updated_at: string; + last_ok?: string; + last_error?: string; + restart_count: number; +} + +export interface HealthSnapshot { + pid: number; + updated_at: string; + uptime_seconds: number; + components: Record; +} + +export interface DaemonUserState { + status: DaemonStatus; + healthSnapshot: HealthSnapshot | null; + components: { + gateway?: ComponentHealth; + channels?: ComponentHealth; + heartbeat?: ComponentHealth; + scheduler?: ComponentHealth; + }; + lastHealthUpdate: string | null; + connectionAttempts: number; + autoStartEnabled: boolean; + isRecovering: boolean; + healthTimeoutId: string | null; +} + +const initialUserState: DaemonUserState = { + status: 'disconnected', + healthSnapshot: null, + components: {}, + lastHealthUpdate: null, + connectionAttempts: 0, + autoStartEnabled: false, + isRecovering: false, + healthTimeoutId: null, +}; + +interface DaemonState { + /** Daemon state per user id. Use __pending__ when user not loaded yet. */ + byUser: Record; +} + +const initialState: DaemonState = { byUser: {} }; + +const ensureUserState = (state: DaemonState, userId: string): DaemonUserState => { + if (!state.byUser[userId]) { + state.byUser[userId] = { ...initialUserState }; + } + return state.byUser[userId]; +}; + +const daemonSlice = createSlice({ + name: 'daemon', + initialState, + reducers: { + updateHealthSnapshot: ( + state, + action: PayloadAction<{ userId: string; healthSnapshot: HealthSnapshot }> + ) => { + const { userId, healthSnapshot } = action.payload; + const user = ensureUserState(state, userId); + + user.healthSnapshot = healthSnapshot; + user.lastHealthUpdate = new Date().toISOString(); + + // Update component health + user.components = healthSnapshot.components; + + // Determine overall daemon status based on component health + const componentStatuses = Object.values(healthSnapshot.components).map(c => c.status); + + if (componentStatuses.length === 0) { + user.status = 'disconnected'; + } else if (componentStatuses.every(status => status === 'ok')) { + user.status = 'running'; + user.isRecovering = false; + user.connectionAttempts = 0; + } else if (componentStatuses.some(status => status === 'error')) { + user.status = 'error'; + } else if (componentStatuses.some(status => status === 'starting')) { + user.status = 'starting'; + } + }, + + setDaemonStatus: ( + state, + action: PayloadAction<{ userId: string; status: DaemonStatus }> + ) => { + const { userId, status } = action.payload; + const user = ensureUserState(state, userId); + user.status = status; + + if (status === 'disconnected') { + user.healthSnapshot = null; + user.components = {}; + user.lastHealthUpdate = null; + } + }, + + incrementConnectionAttempts: ( + state, + action: PayloadAction<{ userId: string }> + ) => { + const { userId } = action.payload; + const user = ensureUserState(state, userId); + user.connectionAttempts += 1; + }, + + resetConnectionAttempts: ( + state, + action: PayloadAction<{ userId: string }> + ) => { + const { userId } = action.payload; + const user = ensureUserState(state, userId); + user.connectionAttempts = 0; + }, + + setAutoStartEnabled: ( + state, + action: PayloadAction<{ userId: string; enabled: boolean }> + ) => { + const { userId, enabled } = action.payload; + const user = ensureUserState(state, userId); + user.autoStartEnabled = enabled; + }, + + setIsRecovering: ( + state, + action: PayloadAction<{ userId: string; isRecovering: boolean }> + ) => { + const { userId, isRecovering } = action.payload; + const user = ensureUserState(state, userId); + user.isRecovering = isRecovering; + }, + + setHealthTimeoutId: ( + state, + action: PayloadAction<{ userId: string; timeoutId: string | null }> + ) => { + const { userId, timeoutId } = action.payload; + const user = ensureUserState(state, userId); + user.healthTimeoutId = timeoutId; + }, + + resetForUser: (state, action: PayloadAction<{ userId: string }>) => { + const { userId } = action.payload; + state.byUser[userId] = { ...initialUserState }; + }, + }, +}); + +export const { + updateHealthSnapshot, + setDaemonStatus, + incrementConnectionAttempts, + resetConnectionAttempts, + setAutoStartEnabled, + setIsRecovering, + setHealthTimeoutId, + resetForUser, +} = daemonSlice.actions; + +// Selectors +export const selectDaemonStateForUser = (state: { daemon: DaemonState }, userId?: string) => { + const uid = userId || '__pending__'; + return state.daemon.byUser[uid] || initialUserState; +}; + +export const selectDaemonStatus = (state: { daemon: DaemonState }, userId?: string) => { + const daemonState = selectDaemonStateForUser(state, userId); + return daemonState.status; +}; + +export const selectDaemonComponents = (state: { daemon: DaemonState }, userId?: string) => { + const daemonState = selectDaemonStateForUser(state, userId); + return daemonState.components; +}; + +export const selectDaemonHealthSnapshot = (state: { daemon: DaemonState }, userId?: string) => { + const daemonState = selectDaemonStateForUser(state, userId); + return daemonState.healthSnapshot; +}; + +export const selectDaemonLastHealthUpdate = (state: { daemon: DaemonState }, userId?: string) => { + const daemonState = selectDaemonStateForUser(state, userId); + return daemonState.lastHealthUpdate; +}; + +export const selectIsDaemonAutoStartEnabled = (state: { daemon: DaemonState }, userId?: string) => { + const daemonState = selectDaemonStateForUser(state, userId); + return daemonState.autoStartEnabled; +}; + +export const selectDaemonConnectionAttempts = (state: { daemon: DaemonState }, userId?: string) => { + const daemonState = selectDaemonStateForUser(state, userId); + return daemonState.connectionAttempts; +}; + +export const selectIsDaemonRecovering = (state: { daemon: DaemonState }, userId?: string) => { + const daemonState = selectDaemonStateForUser(state, userId); + return daemonState.isRecovering; +}; + +export default daemonSlice.reducer; \ No newline at end of file diff --git a/src/store/index.ts b/src/store/index.ts index 7d6932041..c3a9fb227 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -17,6 +17,7 @@ import { IS_DEV } from '../utils/config'; import { storeSession } from '../utils/tauriCommands'; import aiReducer from './aiSlice'; import authReducer, { setOnboardedForUser, setToken } from './authSlice'; +import daemonReducer from './daemonSlice'; import inviteReducer from './inviteSlice'; import skillsReducer from './skillsSlice'; import socketReducer from './socketSlice'; @@ -81,6 +82,7 @@ export const store = configureStore({ auth: persistedAuthReducer, socket: socketReducer, user: userReducer, + daemon: daemonReducer, ai: persistedAiReducer, skills: persistedSkillsReducer, team: teamReducer, diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 000000000..0708f5e30 --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1,11 @@ +// Global type declarations for the application + +declare global { + interface Window { + __TAURI__?: { + [key: string]: unknown; + }; + } +} + +export {}; \ No newline at end of file diff --git a/src/utils/tauriSocket.ts b/src/utils/tauriSocket.ts index 393296b70..717f78837 100644 --- a/src/utils/tauriSocket.ts +++ b/src/utils/tauriSocket.ts @@ -17,13 +17,18 @@ import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core'; import { listen, UnlistenFn } from '@tauri-apps/api/event'; import { syncToolsToBackend } from '../lib/skills/sync'; +import { daemonHealthService } from '../services/daemonHealthService'; import { store } from '../store'; import { setSocketIdForUser, setStatusForUser } from '../store/socketSlice'; import { BACKEND_URL } from './config'; // Check if we're running in Tauri export const isTauri = (): boolean => { - return coreIsTauri(); + const isTauriEnv = coreIsTauri(); + const windowTauri = typeof window !== 'undefined' ? !!window.__TAURI__ : 'undefined'; + const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'undefined'; + console.log('[TauriSocket] isTauri() check:', isTauriEnv, 'window.__TAURI__:', windowTauri, 'userAgent:', userAgent); + return isTauriEnv; }; // --------------------------------------------------------------------------- @@ -102,6 +107,7 @@ let unlistenConnect: UnlistenFn | null = null; let unlistenDisconnect: UnlistenFn | null = null; let unlistenSocketState: UnlistenFn | null = null; let unlistenServerEvent: UnlistenFn | null = null; +let unlistenDaemonHealth: UnlistenFn | null = null; function getSocketUserId(): string { const token = store.getState().auth.token; @@ -124,10 +130,17 @@ function getSocketUserId(): string { * This should be called once when the app starts in Tauri mode. */ export async function setupTauriSocketListeners(): Promise { - if (!isTauri()) return; + console.log('[TauriSocket] setupTauriSocketListeners() called'); + if (!isTauri()) { + console.log('[TauriSocket] Not in Tauri environment, returning early'); + return; + } + console.log('[TauriSocket] In Tauri environment, proceeding with listener setup'); try { + console.log('[TauriSocket] Starting listener setup sequence'); // Listen for Rust socket state changes (Phase 2 — primary) + console.log('[TauriSocket] Setting up runtime:socket-state-changed listener'); unlistenSocketState = await listen<{ status: string; socket_id: string | null }>( 'runtime:socket-state-changed', event => { @@ -156,14 +169,18 @@ export async function setupTauriSocketListeners(): Promise { } } ); + console.log('[TauriSocket] runtime:socket-state-changed listener setup complete'); // Listen for forwarded server events + console.log('[TauriSocket] Setting up server:event listener'); unlistenServerEvent = await listen<{ event: string; data: unknown }>('server:event', event => { console.log('[TauriSocket] Server event:', event.payload.event, event.payload.data); // Future: dispatch to specific handlers based on event type }); + console.log('[TauriSocket] server:event listener setup complete'); // Legacy: Listen for connect requests from Rust (backwards compat) + console.log('[TauriSocket] Setting up legacy socket:should_connect listener'); unlistenConnect = await listen<{ backendUrl: string; token: string }>( 'socket:should_connect', async event => { @@ -172,12 +189,20 @@ export async function setupTauriSocketListeners(): Promise { void event; } ); + console.log('[TauriSocket] socket:should_connect listener setup complete'); // Legacy: Listen for disconnect requests from Rust + console.log('[TauriSocket] Setting up legacy socket:should_disconnect listener'); unlistenDisconnect = await listen('socket:should_disconnect', async () => { console.log('[TauriSocket] Legacy disconnect request (ignored — using Rust socket)'); // No-op: Rust socket handles disconnection now }); + console.log('[TauriSocket] socket:should_disconnect listener setup complete'); + + // Setup daemon health monitoring + console.log('[TauriSocket] About to setup daemon health listener'); + unlistenDaemonHealth = await daemonHealthService.setupHealthListener(); + console.log('[TauriSocket] Daemon health listener setup result:', unlistenDaemonHealth); console.log('[TauriSocket] Event listeners setup complete'); } catch (error) { @@ -205,6 +230,13 @@ export function cleanupTauriSocketListeners(): void { unlistenServerEvent(); unlistenServerEvent = null; } + if (unlistenDaemonHealth) { + unlistenDaemonHealth(); + unlistenDaemonHealth = null; + } + + // Cleanup daemon health service + daemonHealthService.cleanup(); } // ---------------------------------------------------------------------------