//! `TauriEventRelay` — bridges the domain [`EventBus`] to Tauri events //! (backend → frontend push channel, ARCHITECTURE §2 "Events"). //! //! The relay subscribes to the bus and re-emits each [`DomainEvent`] as a Tauri //! event named [`DOMAIN_EVENT`], carrying a serialisable [`DomainEventDto`] //! payload (the domain event itself is deliberately not `Serialize`; the wire //! format is owned here, the infrastructure/presentation layer). //! //! High-frequency `PtyOutput` is intentionally *not* relayed through this global //! event; it goes through per-session [`crate::pty::PtyBridge`] channels instead. use serde::Serialize; use tauri::{AppHandle, Emitter}; use domain::events::DomainEvent; use infrastructure::TokioBroadcastEventBus; /// Name of the Tauri event carrying relayed [`DomainEvent`]s. pub const DOMAIN_EVENT: &str = "domain://event"; /// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged). /// /// `type` is the discriminant; payload fields are flattened per variant. This is /// the single owner of the event wire format on the backend side. #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum DomainEventDto { /// A project was created. #[serde(rename_all = "camelCase")] ProjectCreated { /// Project id (UUID string). project_id: String, }, /// An agent was launched. #[serde(rename_all = "camelCase")] AgentLaunched { /// Agent id. agent_id: String, /// Session id. session_id: String, }, /// An agent exited. #[serde(rename_all = "camelCase")] AgentExited { /// Agent id. agent_id: String, /// Exit code. code: i32, }, /// A template was updated. #[serde(rename_all = "camelCase")] TemplateUpdated { /// Template id. template_id: String, /// New version. version: u64, }, /// A synchronized agent drifted from its template. #[serde(rename_all = "camelCase")] AgentDriftDetected { /// Agent id. agent_id: String, /// Current version. from: u64, /// Available version. to: u64, }, /// A synchronized agent was brought up to date. #[serde(rename_all = "camelCase")] AgentSynced { /// Agent id. agent_id: String, /// Version synced to. to: u64, }, /// A skill was assigned to (or unassigned from) an agent. #[serde(rename_all = "camelCase")] SkillAssigned { /// Agent id. agent_id: String, /// Skill id. skill_id: String, /// `true` if assigned, `false` if unassigned. assigned: bool, }, /// A tab's layout changed. #[serde(rename_all = "camelCase")] LayoutChanged { /// Project id. project_id: String, }, /// A remote connection was established. #[serde(rename_all = "camelCase")] RemoteConnected { /// Project id. project_id: String, }, /// Git state changed. #[serde(rename_all = "camelCase")] GitStateChanged { /// Project id. project_id: String, }, /// An orchestrator request was processed on behalf of a requester agent. #[serde(rename_all = "camelCase")] OrchestratorRequestProcessed { /// Id of the requesting (orchestrator) agent. requester_id: String, /// The action that was processed. action: String, /// Whether IdeA handled it successfully. ok: bool, }, /// A memory note was created or updated. #[serde(rename_all = "camelCase")] MemorySaved { /// The saved note's slug. slug: String, }, /// A memory note was deleted. #[serde(rename_all = "camelCase")] MemoryDeleted { /// The deleted note's slug. slug: String, }, /// The aggregated `MEMORY.md` index was rebuilt. #[serde(rename_all = "camelCase")] MemoryIndexRebuilt { /// Project id. project_id: String, }, /// A project's memory crossed the recall budget while no embedder is configured /// (LOT C3 — §14.5.5): a one-time, dismissible "configure an embedder?" hint. #[serde(rename_all = "camelCase")] EmbedderSuggested { /// Project id. project_id: String, /// Whether a local Ollama-style embedding server was detected. ollama_detected: bool, /// Ids of recommended ONNX models already present in the local cache. onnx_cached: Vec, /// Whether the HTTP capability is compiled in. vector_http_enabled: bool, /// Whether the in-process ONNX capability is compiled in. vector_onnx_enabled: bool, }, /// Raw PTY output (normally routed to a per-session channel, not here). #[serde(rename_all = "camelCase")] PtyOutput { /// Session id. session_id: String, /// Output bytes. bytes: Vec, }, } impl From<&DomainEvent> for DomainEventDto { fn from(e: &DomainEvent) -> Self { match e { DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated { project_id: project_id.to_string(), }, DomainEvent::AgentLaunched { agent_id, session_id, } => Self::AgentLaunched { agent_id: agent_id.to_string(), session_id: session_id.to_string(), }, DomainEvent::AgentExited { agent_id, code } => Self::AgentExited { agent_id: agent_id.to_string(), code: *code, }, DomainEvent::TemplateUpdated { template_id, version, } => Self::TemplateUpdated { template_id: template_id.to_string(), version: version.get(), }, DomainEvent::AgentDriftDetected { agent_id, from, to } => Self::AgentDriftDetected { agent_id: agent_id.to_string(), from: from.get(), to: to.get(), }, DomainEvent::AgentSynced { agent_id, to } => Self::AgentSynced { agent_id: agent_id.to_string(), to: to.get(), }, DomainEvent::SkillAssigned { agent_id, skill_id, assigned, } => Self::SkillAssigned { agent_id: agent_id.to_string(), skill_id: skill_id.to_string(), assigned: *assigned, }, DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged { project_id: project_id.to_string(), }, DomainEvent::RemoteConnected { project_id } => Self::RemoteConnected { project_id: project_id.to_string(), }, DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged { project_id: project_id.to_string(), }, DomainEvent::OrchestratorRequestProcessed { requester_id, action, ok, } => Self::OrchestratorRequestProcessed { requester_id: requester_id.clone(), action: action.clone(), ok: *ok, }, DomainEvent::MemorySaved { slug } => Self::MemorySaved { slug: slug.as_str().to_string(), }, DomainEvent::MemoryDeleted { slug } => Self::MemoryDeleted { slug: slug.as_str().to_string(), }, DomainEvent::MemoryIndexRebuilt { project_id } => Self::MemoryIndexRebuilt { project_id: project_id.to_string(), }, DomainEvent::EmbedderSuggested { project_id, ollama_detected, onnx_cached, vector_http_enabled, vector_onnx_enabled, } => Self::EmbedderSuggested { project_id: project_id.to_string(), ollama_detected: *ollama_detected, onnx_cached: onnx_cached.clone(), vector_http_enabled: *vector_http_enabled, vector_onnx_enabled: *vector_onnx_enabled, }, DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput { session_id: session_id.to_string(), bytes: bytes.clone(), }, } } } /// Subscribes the relay to the bus and spawns a background task that forwards /// every [`DomainEvent`] to the frontend as a [`DOMAIN_EVENT`] Tauri event. /// /// Uses the bus's raw async broadcast receiver so the relay runs cooperatively /// on the Tokio runtime (no blocking thread). Returns immediately; the spawned /// task lives for the duration of the app. pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) { use tokio::sync::broadcast::error::RecvError; let mut rx = bus.raw_receiver(); tauri::async_runtime::spawn(async move { loop { match rx.recv().await { Ok(event) => { // Skip high-frequency PTY output on the global channel. if matches!(event, DomainEvent::PtyOutput { .. }) { continue; } let dto = DomainEventDto::from(&event); let _ = app.emit(DOMAIN_EVENT, dto); } // The bus dropped some events for this slow receiver; keep going. Err(RecvError::Lagged(_)) => continue, // The bus was dropped (app shutting down); stop the relay. Err(RecvError::Closed) => break, } } }); }