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    // ========== Verification Events ==========
119    /// Model verification progress update.
120    VerificationProgress {
121        /// ID of the model being verified.
122        #[serde(rename = "modelId")]
123        model_id: i64,
124        /// Name of the model being verified.
125        #[serde(rename = "modelName")]
126        model_name: String,
127        /// Name of the shard being verified.
128        #[serde(rename = "shardName")]
129        shard_name: String,
130        /// Bytes processed so far.
131        #[serde(rename = "bytesProcessed")]
132        bytes_processed: u64,
133        /// Total bytes to process.
134        #[serde(rename = "totalBytes")]
135        total_bytes: u64,
136    },
137
138    /// Model verification completed.
139    VerificationComplete {
140        /// ID of the verified model.
141        #[serde(rename = "modelId")]
142        model_id: i64,
143        /// Name of the verified model.
144        #[serde(rename = "modelName")]
145        model_name: String,
146        /// Overall health status.
147        #[serde(rename = "overallHealth")]
148        overall_health: crate::services::OverallHealth,
149    },
150
151    /// Server health status has changed.
152    ///
153    /// Emitted by continuous monitoring when a server's health state changes.
154    ServerHealthChanged {
155        /// Unique server instance identifier.
156        #[serde(rename = "serverId")]
157        server_id: i64,
158        /// ID of the model being served.
159        #[serde(rename = "modelId")]
160        model_id: i64,
161        /// New health status.
162        status: crate::ports::ServerHealthStatus,
163        /// Optional detail message (e.g., error description).
164        #[serde(skip_serializing_if = "Option::is_none")]
165        detail: Option<String>,
166        /// Unix timestamp in milliseconds when status changed.
167        timestamp: u64,
168    },
169
170    // ========== MCP Server Events ==========
171    /// An MCP server was added to the configuration.
172    McpServerAdded {
173        /// Summary of the added server.
174        server: McpServerSummary,
175    },
176
177    /// An MCP server was removed from the configuration.
178    McpServerRemoved {
179        /// ID of the removed server.
180        #[serde(rename = "serverId")]
181        server_id: i64,
182    },
183
184    /// An MCP server has started and is ready.
185    McpServerStarted {
186        /// ID of the server.
187        #[serde(rename = "serverId")]
188        server_id: i64,
189        /// Name of the server.
190        #[serde(rename = "serverName")]
191        server_name: String,
192    },
193
194    /// An MCP server has been stopped.
195    McpServerStopped {
196        /// ID of the server.
197        #[serde(rename = "serverId")]
198        server_id: i64,
199        /// Name of the server.
200        #[serde(rename = "serverName")]
201        server_name: String,
202    },
203
204    /// An MCP server encountered an error.
205    McpServerError {
206        /// User-safe error information.
207        error: McpErrorInfo,
208    },
209
210    // ========== Voice Events ==========
211    /// Voice pipeline state changed (idle → listening → recording → …).
212    VoiceStateChanged {
213        /// Lowercase state label: `"idle"`, `"listening"`, `"recording"`,
214        /// `"transcribing"`, `"thinking"`, `"speaking"`, or `"error"`.
215        state: String,
216    },
217
218    /// Speech transcript produced by the STT engine.
219    VoiceTranscript {
220        /// Transcript text.
221        text: String,
222        /// Whether this is a final (committed) transcript.
223        #[serde(rename = "isFinal")]
224        is_final: bool,
225    },
226
227    /// TTS playback has started.
228    VoiceSpeakingStarted,
229
230    /// TTS playback has finished.
231    VoiceSpeakingFinished,
232
233    /// Microphone audio level sample (0.0 – 1.0) for UI visualisation.
234    ///
235    /// Throttled to ≤ 20 fps at the SSE bridge before entering the bus.
236    VoiceAudioLevel {
237        /// Normalised audio level in `[0.0, 1.0]`.
238        level: f32,
239    },
240
241    /// Voice pipeline encountered a non-fatal error.
242    VoiceError {
243        /// Human-readable error message.
244        message: String,
245    },
246
247    /// Download progress for a voice model (STT / TTS / VAD).
248    ///
249    /// Emitted by `VoiceService` during `download_stt_model`,
250    /// `download_tts_model`, and `download_vad_model` calls so that
251    /// SSE subscribers receive live progress without Tauri's `app.emit()`.
252    VoiceModelDownloadProgress {
253        /// Identifier of the model being downloaded (e.g. `"base.en"`).
254        #[serde(rename = "modelId")]
255        model_id: String,
256        /// Bytes downloaded so far.
257        #[serde(rename = "bytesDownloaded")]
258        bytes_downloaded: u64,
259        /// Total bytes to download (`0` if the server did not send
260        /// `Content-Length`).
261        #[serde(rename = "totalBytes")]
262        total_bytes: u64,
263        /// Progress percentage (`0.0`–`100.0`; `0.0` when total is unknown).
264        percent: f64,
265    },
266
267    // ========== Proxy Events ==========
268    /// The OpenAI-compatible proxy has started.
269    ProxyStarted {
270        /// Port the proxy is listening on.
271        port: u16,
272    },
273
274    /// The proxy has been stopped (clean shutdown).
275    ProxyStopped,
276
277    /// The proxy crashed (task exited without cancellation).
278    ProxyCrashed,
279}
280
281impl AppEvent {
282    /// Get the event name for wire protocols.
283    ///
284    /// This provides consistent event naming across Tauri and SSE transports.
285    pub const fn event_name(&self) -> &'static str {
286        match self {
287            Self::ServerStarted { .. } => "server:started",
288            Self::ServerStopped { .. } => "server:stopped",
289            Self::ServerError { .. } => "server:error",
290            Self::ServerSnapshot { .. } => "server:snapshot",
291            Self::ServerHealthChanged { .. } => "server:health_changed",
292            Self::Download { event } => event.event_name(),
293            Self::ModelAdded { .. } => "model:added",
294            Self::ModelRemoved { .. } => "model:removed",
295            Self::ModelUpdated { .. } => "model:updated",
296            Self::VerificationProgress { .. } => "verification:progress",
297            Self::VerificationComplete { .. } => "verification:complete",
298            Self::McpServerAdded { .. } => "mcp:added",
299            Self::McpServerRemoved { .. } => "mcp:removed",
300            Self::McpServerStarted { .. } => "mcp:started",
301            Self::McpServerStopped { .. } => "mcp:stopped",
302            Self::McpServerError { .. } => "mcp:error",
303            Self::VoiceStateChanged { .. } => "voice:state-changed",
304            Self::VoiceTranscript { .. } => "voice:transcript",
305            Self::VoiceSpeakingStarted => "voice:speaking-started",
306            Self::VoiceSpeakingFinished => "voice:speaking-finished",
307            Self::VoiceAudioLevel { .. } => "voice:audio-level",
308            Self::VoiceError { .. } => "voice:error",
309            Self::VoiceModelDownloadProgress { .. } => "voice:model-download-progress",
310            Self::ProxyStarted { .. } => "proxy:started",
311            Self::ProxyStopped => "proxy:stopped",
312            Self::ProxyCrashed => "proxy:crashed",
313        }
314    }
315}
316
317impl AppEvent {
318    /// Create a [`VoiceStateChanged`] event.
319    pub fn voice_state_changed(state: impl Into<String>) -> Self {
320        Self::VoiceStateChanged {
321            state: state.into(),
322        }
323    }
324
325    /// Create a [`VoiceTranscript`] event.
326    pub fn voice_transcript(text: impl Into<String>, is_final: bool) -> Self {
327        Self::VoiceTranscript {
328            text: text.into(),
329            is_final,
330        }
331    }
332
333    /// Create a [`VoiceSpeakingStarted`] event.
334    pub const fn voice_speaking_started() -> Self {
335        Self::VoiceSpeakingStarted
336    }
337
338    /// Create a [`VoiceSpeakingFinished`] event.
339    pub const fn voice_speaking_finished() -> Self {
340        Self::VoiceSpeakingFinished
341    }
342
343    /// Create a [`VoiceAudioLevel`] event.
344    pub const fn voice_audio_level(level: f32) -> Self {
345        Self::VoiceAudioLevel { level }
346    }
347
348    /// Create a [`VoiceError`] event.
349    pub fn voice_error(message: impl Into<String>) -> Self {
350        Self::VoiceError {
351            message: message.into(),
352        }
353    }
354}
355
356impl AppEvent {
357    /// Create a [`ProxyStarted`] event.
358    pub const fn proxy_started(port: u16) -> Self {
359        Self::ProxyStarted { port }
360    }
361
362    /// Create a [`ProxyStopped`] event.
363    pub const fn proxy_stopped() -> Self {
364        Self::ProxyStopped
365    }
366
367    /// Create a [`ProxyCrashed`] event.
368    pub const fn proxy_crashed() -> Self {
369        Self::ProxyCrashed
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn test_event_serialization() {
379        let event = AppEvent::server_started(1, "Llama-2-7B", 8080);
380        let json = serde_json::to_string(&event).unwrap();
381        assert!(json.contains("\"type\":\"server_started\""));
382        assert!(json.contains("\"modelName\":\"Llama-2-7B\""));
383        assert!(json.contains("\"port\":8080"));
384    }
385
386    #[test]
387    fn test_event_names() {
388        assert_eq!(
389            AppEvent::server_started(1, "test", 8080).event_name(),
390            "server:started"
391        );
392        assert_eq!(
393            AppEvent::download_started("id", "name").event_name(),
394            "download:started"
395        );
396        assert_eq!(AppEvent::model_removed(1).event_name(), "model:removed");
397    }
398
399    /// Lock down download event names to prevent frontend subscription mismatches.
400    ///
401    /// This test protects the contract between backend event emission and frontend
402    /// Tauri event subscription. If this test fails, update the `DOWNLOAD_EVENT_NAMES`
403    /// constant in src/services/transport/events/eventNames.ts to match.
404    ///
405    /// Context: Issue where Tauri GUI downloads started but progress UI never appeared
406    /// because frontend listened to wrong event names.
407    #[test]
408    fn download_event_names_are_stable() {
409        let cases = vec![
410            (AppEvent::download_started("id", "name"), "download:started"),
411            (
412                AppEvent::download_progress("id", 50, 100, 1024.0, 10.0, 50.0),
413                "download:progress",
414            ),
415            (
416                AppEvent::download_completed("id", None),
417                "download:completed",
418            ),
419            (AppEvent::download_failed("id", "error"), "download:failed"),
420            (AppEvent::download_cancelled("id"), "download:cancelled"),
421        ];
422
423        for (event, expected_name) in cases {
424            assert_eq!(event.event_name(), expected_name);
425        }
426    }
427
428    /// Lock down voice event names to prevent frontend subscription mismatches.
429    ///
430    /// Both the Serde `type` tag (SSE/WebUI path) and the `event_name()` return
431    /// (Tauri IPC path) are validated here.
432    ///
433    /// If this test fails, update `VOICE_EVENT_NAMES` in
434    /// `src/services/transport/events/eventNames.ts` to match.
435    #[test]
436    fn voice_event_names_are_stable() {
437        let cases = vec![
438            (AppEvent::voice_state_changed("idle"), "voice:state-changed"),
439            (
440                AppEvent::voice_transcript("hello", true),
441                "voice:transcript",
442            ),
443            (AppEvent::voice_speaking_started(), "voice:speaking-started"),
444            (
445                AppEvent::voice_speaking_finished(),
446                "voice:speaking-finished",
447            ),
448            (AppEvent::voice_audio_level(0.5), "voice:audio-level"),
449            (AppEvent::voice_error("oops"), "voice:error"),
450        ];
451        for (event, expected_name) in cases {
452            assert_eq!(event.event_name(), expected_name);
453        }
454
455        // Also assert Serde type tags match the frontend SSE routing prefix.
456        let json = serde_json::to_string(&AppEvent::voice_state_changed("idle")).unwrap();
457        assert!(
458            json.contains("\"type\":\"voice_state_changed\""),
459            "bad serde tag: {json}"
460        );
461
462        let json = serde_json::to_string(&AppEvent::voice_audio_level(0.5)).unwrap();
463        assert!(
464            json.contains("\"type\":\"voice_audio_level\""),
465            "bad serde tag: {json}"
466        );
467
468        let json = serde_json::to_string(&AppEvent::voice_error("oops")).unwrap();
469        assert!(
470            json.contains("\"type\":\"voice_error\""),
471            "bad serde tag: {json}"
472        );
473    }
474}