gglib_core/events/
mod.rs

1//! Canonical event union for all cross-adapter events.
2//!
3//! This module is the single source of truth for events used by Tauri listeners,
4//! SSE handlers, and backend emitters.
5//!
6//! # Structure
7//!
8//! - `app` - Application-level events (model added/removed/updated)
9//! - `download` - Download progress and completion events
10//! - `server` - Model server lifecycle events
11//! - `mcp` - MCP server lifecycle events
12//!
13//! # Wire Format
14//!
15//! Events are serialized with a `type` tag for TypeScript compatibility:
16//!
17//! ```json
18//! { "type": "server_started", "modelName": "Llama-2-7B", "port": 8080 }
19//! ```
20
21mod app;
22mod download;
23mod mcp;
24mod server;
25
26use serde::{Deserialize, Serialize};
27
28use crate::ports::McpErrorInfo;
29
30// Re-export event types
31pub use app::ModelSummary;
32pub use mcp::McpServerSummary;
33pub use server::{NoopServerEvents, ServerEvents, ServerSnapshotEntry, ServerSummary};
34
35// Import download types for AppEvent::Download wrapper
36use crate::download::DownloadEvent;
37
38/// Canonical event types for all adapters.
39///
40/// This enum unifies server, download, and model events into a single
41/// discriminated union. Each variant includes all necessary context
42/// for the event to be self-describing.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(tag = "type", rename_all = "snake_case")]
45pub enum AppEvent {
46    // ========== Server Events ==========
47    /// A model server has started and is ready to accept requests.
48    ServerStarted {
49        /// ID of the model being served.
50        #[serde(rename = "modelId")]
51        model_id: i64,
52        /// Name of the model being served.
53        #[serde(rename = "modelName")]
54        model_name: String,
55        /// Port the server is listening on.
56        port: u16,
57    },
58
59    /// A model server has been stopped (clean shutdown).
60    ServerStopped {
61        /// ID of the model that was being served.
62        #[serde(rename = "modelId")]
63        model_id: i64,
64        /// Name of the model that was being served.
65        #[serde(rename = "modelName")]
66        model_name: String,
67    },
68
69    /// A model server encountered an error.
70    ServerError {
71        /// ID of the model being served (if known).
72        #[serde(rename = "modelId")]
73        model_id: Option<i64>,
74        /// Name of the model being served.
75        #[serde(rename = "modelName")]
76        model_name: String,
77        /// Error description.
78        error: String,
79    },
80
81    /// Snapshot of all currently running servers.
82    ServerSnapshot {
83        /// List of currently running servers.
84        servers: Vec<ServerSnapshotEntry>,
85    },
86
87    // ========== Download Events ==========
88    /// Download lifecycle + progress events (including shard progress).
89    ///
90    /// Wraps `DownloadEvent` verbatim to preserve all detail including
91    /// shard-specific progress information.
92    #[serde(rename = "download")]
93    Download {
94        /// The download event payload.
95        event: DownloadEvent,
96    },
97
98    // ========== Model Events ==========
99    /// A model was added to the library.
100    ModelAdded {
101        /// Summary of the added model.
102        model: ModelSummary,
103    },
104
105    /// A model was removed from the library.
106    ModelRemoved {
107        /// ID of the removed model.
108        #[serde(rename = "modelId")]
109        model_id: i64,
110    },
111
112    /// A model was updated in the library.
113    ModelUpdated {
114        /// Summary of the updated model.
115        model: ModelSummary,
116    },
117
118    /// Server health status has changed.
119    ///
120    /// Emitted by continuous monitoring when a server's health state changes.
121    ServerHealthChanged {
122        /// Unique server instance identifier.
123        #[serde(rename = "serverId")]
124        server_id: i64,
125        /// ID of the model being served.
126        #[serde(rename = "modelId")]
127        model_id: i64,
128        /// New health status.
129        status: crate::ports::ServerHealthStatus,
130        /// Optional detail message (e.g., error description).
131        #[serde(skip_serializing_if = "Option::is_none")]
132        detail: Option<String>,
133        /// Unix timestamp in milliseconds when status changed.
134        timestamp: u64,
135    },
136
137    // ========== MCP Server Events ==========
138    /// An MCP server was added to the configuration.
139    McpServerAdded {
140        /// Summary of the added server.
141        server: McpServerSummary,
142    },
143
144    /// An MCP server was removed from the configuration.
145    McpServerRemoved {
146        /// ID of the removed server.
147        #[serde(rename = "serverId")]
148        server_id: i64,
149    },
150
151    /// An MCP server has started and is ready.
152    McpServerStarted {
153        /// ID of the server.
154        #[serde(rename = "serverId")]
155        server_id: i64,
156        /// Name of the server.
157        #[serde(rename = "serverName")]
158        server_name: String,
159    },
160
161    /// An MCP server has been stopped.
162    McpServerStopped {
163        /// ID of the server.
164        #[serde(rename = "serverId")]
165        server_id: i64,
166        /// Name of the server.
167        #[serde(rename = "serverName")]
168        server_name: String,
169    },
170
171    /// An MCP server encountered an error.
172    McpServerError {
173        /// User-safe error information.
174        error: McpErrorInfo,
175    },
176}
177
178impl AppEvent {
179    /// Get the event name for wire protocols.
180    ///
181    /// This provides consistent event naming across Tauri and SSE transports.
182    pub const fn event_name(&self) -> &'static str {
183        match self {
184            Self::ServerStarted { .. } => "server:started",
185            Self::ServerStopped { .. } => "server:stopped",
186            Self::ServerError { .. } => "server:error",
187            Self::ServerSnapshot { .. } => "server:snapshot",
188            Self::ServerHealthChanged { .. } => "server:health_changed",
189            Self::Download { event } => event.event_name(),
190            Self::ModelAdded { .. } => "model:added",
191            Self::ModelRemoved { .. } => "model:removed",
192            Self::ModelUpdated { .. } => "model:updated",
193            Self::McpServerAdded { .. } => "mcp:added",
194            Self::McpServerRemoved { .. } => "mcp:removed",
195            Self::McpServerStarted { .. } => "mcp:started",
196            Self::McpServerStopped { .. } => "mcp:stopped",
197            Self::McpServerError { .. } => "mcp:error",
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_event_serialization() {
208        let event = AppEvent::server_started(1, "Llama-2-7B", 8080);
209        let json = serde_json::to_string(&event).unwrap();
210        assert!(json.contains("\"type\":\"server_started\""));
211        assert!(json.contains("\"modelName\":\"Llama-2-7B\""));
212        assert!(json.contains("\"port\":8080"));
213    }
214
215    #[test]
216    fn test_event_names() {
217        assert_eq!(
218            AppEvent::server_started(1, "test", 8080).event_name(),
219            "server:started"
220        );
221        assert_eq!(
222            AppEvent::download_started("id", "name").event_name(),
223            "download:started"
224        );
225        assert_eq!(AppEvent::model_removed(1).event_name(), "model:removed");
226    }
227
228    /// Lock down download event names to prevent frontend subscription mismatches.
229    ///
230    /// This test protects the contract between backend event emission and frontend
231    /// Tauri event subscription. If this test fails, update the `DOWNLOAD_EVENT_NAMES`
232    /// constant in src/services/transport/events/eventNames.ts to match.
233    ///
234    /// Context: Issue where Tauri GUI downloads started but progress UI never appeared
235    /// because frontend listened to wrong event names.
236    #[test]
237    fn download_event_names_are_stable() {
238        let cases = vec![
239            (AppEvent::download_started("id", "name"), "download:started"),
240            (
241                AppEvent::download_progress("id", 50, 100, 1024.0, 10.0, 50.0),
242                "download:progress",
243            ),
244            (
245                AppEvent::download_completed("id", None),
246                "download:completed",
247            ),
248            (AppEvent::download_failed("id", "error"), "download:failed"),
249            (AppEvent::download_cancelled("id"), "download:cancelled"),
250        ];
251
252        for (event, expected_name) in cases {
253            assert_eq!(event.event_name(), expected_name);
254        }
255    }
256}