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}