intent_engine/dashboard/
cli_notifier.rs

1/// CLI Notifier - HTTP client for notifying Dashboard of CLI operations
2///
3/// This module provides a simple HTTP notification mechanism to inform the
4/// Dashboard when CLI commands modify the database. The Dashboard will then
5/// broadcast changes to connected WebSocket clients for real-time UI updates.
6use std::time::Duration;
7
8/// Default Dashboard port
9const DASHBOARD_PORT: u16 = 11391;
10
11/// Notification message types
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum NotificationMessage {
15    /// Task was created/updated/deleted
16    TaskChanged {
17        task_id: Option<i64>,
18        operation: String,
19        /// Project path that sent this notification
20        project_path: Option<String>,
21    },
22    /// Event was added
23    EventAdded {
24        task_id: i64,
25        event_id: i64,
26        /// Project path that sent this notification
27        project_path: Option<String>,
28    },
29    /// Workspace state changed (current_task_id updated)
30    WorkspaceChanged {
31        current_task_id: Option<i64>,
32        /// Project path that sent this notification
33        project_path: Option<String>,
34    },
35}
36
37/// CLI Notifier for sending notifications to Dashboard
38pub struct CliNotifier {
39    base_url: String,
40    client: reqwest::Client,
41}
42
43impl CliNotifier {
44    /// Create a new CLI notifier
45    pub fn new() -> Self {
46        Self::with_port(DASHBOARD_PORT)
47    }
48
49    /// Create a CLI notifier with custom port (for testing)
50    pub fn with_port(port: u16) -> Self {
51        let base_url = format!("http://127.0.0.1:{}", port);
52        let client = reqwest::Client::builder()
53            .timeout(Duration::from_millis(100)) // Short timeout - don't block CLI
54            .build()
55            .unwrap_or_else(|_| reqwest::Client::new());
56
57        Self { base_url, client }
58    }
59
60    /// Send a notification to Dashboard (fire-and-forget, non-blocking)
61    pub async fn notify(&self, message: NotificationMessage) {
62        // Check: Environment variable to disable notifications
63        if std::env::var("IE_DISABLE_DASHBOARD_NOTIFICATIONS")
64            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
65            .unwrap_or(false)
66        {
67            tracing::debug!(
68                "Dashboard notifications disabled via IE_DISABLE_DASHBOARD_NOTIFICATIONS"
69            );
70            return; // Skip all notification logic
71        }
72
73        let url = format!("{}/api/internal/cli-notify", self.base_url);
74
75        // Send notification - short timeout to avoid blocking CLI for too long
76        if let Err(e) = self.client.post(&url).json(&message).send().await {
77            tracing::debug!("Failed to notify Dashboard: {}", e);
78            // Silently ignore errors - Dashboard might not be running
79        }
80    }
81
82    /// Notify about task change
83    pub async fn notify_task_changed(
84        &self,
85        task_id: Option<i64>,
86        operation: &str,
87        project_path: Option<String>,
88    ) {
89        self.notify(NotificationMessage::TaskChanged {
90            task_id,
91            operation: operation.to_string(),
92            project_path,
93        })
94        .await;
95    }
96
97    /// Notify about event added
98    pub async fn notify_event_added(
99        &self,
100        task_id: i64,
101        event_id: i64,
102        project_path: Option<String>,
103    ) {
104        self.notify(NotificationMessage::EventAdded {
105            task_id,
106            event_id,
107            project_path,
108        })
109        .await;
110    }
111
112    /// Notify about workspace change
113    pub async fn notify_workspace_changed(
114        &self,
115        current_task_id: Option<i64>,
116        project_path: Option<String>,
117    ) {
118        self.notify(NotificationMessage::WorkspaceChanged {
119            current_task_id,
120            project_path,
121        })
122        .await;
123    }
124}
125
126impl Default for CliNotifier {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn test_notifier_creation() {
138        let notifier = CliNotifier::new();
139        assert_eq!(notifier.base_url, "http://127.0.0.1:11391");
140    }
141
142    #[test]
143    fn test_notifier_with_custom_port() {
144        let notifier = CliNotifier::with_port(8080);
145        assert_eq!(notifier.base_url, "http://127.0.0.1:8080");
146    }
147
148    #[tokio::test]
149    async fn test_notify_non_blocking() {
150        // This should not panic even if Dashboard is not running
151        let notifier = CliNotifier::with_port(65000); // Port not in use
152        notifier
153            .notify_task_changed(Some(42), "created", Some("/test/path".to_string()))
154            .await;
155
156        // Should return immediately (fire-and-forget)
157        // No assertions needed - test passes if no panic
158    }
159}