//! `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::conversation::ConversationParty; use domain::events::{DomainEvent, OrchestrationSource}; use domain::input::AgentLiveness; use domain::{IssueLinkKind, IssuePriority, IssueStatus}; 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 ticket assistant chat was opened. #[serde(rename_all = "camelCase")] TicketAssistantOpened { /// Bound ticket reference. issue_ref: String, /// Runtime profile id. profile_id: String, }, /// A ticket assistant chat was closed. #[serde(rename_all = "camelCase")] TicketAssistantClosed { /// Bound ticket reference. issue_ref: String, }, /// An agent's busy/idle state changed (cadrage C4 §4.2). The frontend dims /// "Envoyer" while `busy` is `true`. #[serde(rename_all = "camelCase")] AgentBusyChanged { /// Agent id. agent_id: String, /// `true` when a turn is in flight, `false` when idle. busy: bool, }, /// A delegation is ready to be injected into the agent's **native terminal** /// (ARCHITECTURE §20). The frontend write-portal writes `text` + `submitSequence` /// (default `"\r"`) after `submitDelayMs` (default ~60) once the human line is empty, /// then acks via the `delegation_delivered` command. The backend no longer PTY-writes /// the turn. #[serde(rename_all = "camelCase")] DelegationReady { /// Target agent id (UUID string). agent_id: String, /// Mailbox ticket id (UUID string) to ack back via `delegation_delivered`. ticket: String, /// Task text to inject (written without a trailing newline by the portal). text: String, /// Profile's submit sequence; `null` ⇒ the front applies its default (`"\r"`). #[serde(skip_serializing_if = "Option::is_none")] submit_sequence: Option, /// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms). #[serde(skip_serializing_if = "Option::is_none")] submit_delay_ms: Option, }, /// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4). #[serde(rename_all = "camelCase")] AgentReplied { /// Target agent id. agent_id: String, /// Reply length in bytes (metric, not the payload). reply_len: usize, }, /// Intermediate assistant announcement emitted live during an inter-agent turn. #[serde(rename_all = "camelCase")] AgentAnnouncement { /// Project id. project_id: String, /// Requester party (`"user"` or requester agent id). requester: String, /// Target agent id. target: String, /// FIFO ticket id. ticket_id: String, /// Announcement text. text: String, /// Wall-clock timestamp in epoch milliseconds. at_ms: u64, }, /// An agent's liveness (alive/stalled) changed (lot 2, readiness/heartbeat). The /// frontend can badge a frozen agent; `"stalled"` while no proof of liveness arrived /// for longer than the profile's `stallAfterMs`, back to `"alive"` on a late /// battement or when the turn ends. #[serde(rename_all = "camelCase")] AgentLivenessChanged { /// Agent id (UUID string). agent_id: String, /// New liveness, as a lowercase string (`"alive"` / `"stalled"`). liveness: AgentLivenessDto, }, /// A background task lifecycle/delivery event occurred. #[serde(rename_all = "camelCase")] BackgroundTaskChanged { /// Project id. project_id: String, /// Task id. task_id: String, /// Owner agent id. agent_id: String, /// Lightweight event/state label. state: String, }, /// An agent inbox queue depth changed. #[serde(rename_all = "camelCase")] AgentInboxChanged { /// Agent id. agent_id: String, /// Queue depth after the operation. depth: usize, /// Queue operation label. action: String, }, /// An owner wake event occurred. #[serde(rename_all = "camelCase")] AgentWakeChanged { /// Project id. project_id: String, /// Agent id. agent_id: String, /// Wake operation label. action: String, /// Failure reason, when any. #[serde(skip_serializing_if = "Option::is_none")] reason: Option, }, /// An agent's runtime profile was changed (hot-swap of the AI engine). #[serde(rename_all = "camelCase")] AgentProfileChanged { /// Agent id. agent_id: String, /// The new runtime profile id. profile_id: String, }, /// 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 issue-backed public ticket was created. #[serde(rename_all = "camelCase")] IssueCreated { /// Issue id. issue_id: String, /// Public ticket reference (`#N`). issue_ref: String, }, /// An issue-backed public ticket was updated. #[serde(rename_all = "camelCase")] IssueUpdated { /// Public ticket reference (`#N`). issue_ref: String, /// New optimistic version. version: u64, }, /// An issue-backed public ticket was deleted. #[serde(rename_all = "camelCase")] IssueDeleted { /// Project id. project_id: String, /// Deleted public ticket reference (`#N`). issue_ref: String, /// Released sprint id, when any. freed_sprint: Option, }, /// A public ticket status changed. #[serde(rename_all = "camelCase")] IssueStatusChanged { /// Public ticket reference (`#N`). issue_ref: String, /// New status. status: String, /// New optimistic version. version: u64, }, /// A public ticket priority changed. #[serde(rename_all = "camelCase")] IssuePriorityChanged { /// Public ticket reference (`#N`). issue_ref: String, /// New priority. priority: String, /// New optimistic version. version: u64, }, /// A public ticket carnet changed. #[serde(rename_all = "camelCase")] IssueCarnetUpdated { /// Public ticket reference (`#N`). issue_ref: String, /// New optimistic version. version: u64, }, /// A public ticket link was added. #[serde(rename_all = "camelCase")] IssueLinked { /// Public source ticket reference (`#N`). issue_ref: String, /// Public target ticket reference (`#N`). target: String, /// Link kind. kind: String, /// New optimistic version. version: u64, }, /// A public ticket link was removed. #[serde(rename_all = "camelCase")] IssueUnlinked { /// Public source ticket reference (`#N`). issue_ref: String, /// Public target ticket reference (`#N`). target: String, /// Link kind. kind: String, /// New optimistic version. version: u64, }, /// An agent was assigned to a public ticket. #[serde(rename_all = "camelCase")] IssueAgentAssigned { /// Public ticket reference (`#N`). issue_ref: String, /// Assigned agent id. agent_id: String, /// New optimistic version. version: u64, }, /// An agent was unassigned from a public ticket. #[serde(rename_all = "camelCase")] IssueAgentUnassigned { /// Public ticket reference (`#N`). issue_ref: String, /// Unassigned agent id. agent_id: String, /// New optimistic version. version: u64, }, /// A sprint was created. #[serde(rename_all = "camelCase")] SprintCreated { /// Stable sprint id. sprint_id: String, /// Reorderable order. order: u32, }, /// A sprint was renamed. #[serde(rename_all = "camelCase")] SprintRenamed { /// Stable sprint id. sprint_id: String, /// New name. name: String, /// New optimistic version. version: u64, }, /// A sprint was reordered. #[serde(rename_all = "camelCase")] SprintReordered { /// Stable sprint id. sprint_id: String, /// New order. order: u32, /// New optimistic version. version: u64, }, /// A sprint was deleted. #[serde(rename_all = "camelCase")] SprintDeleted { /// Stable sprint id. sprint_id: String, }, /// A ticket changed sprint membership. #[serde(rename_all = "camelCase")] IssueSprintChanged { /// Public ticket reference (`#N`). issue_ref: String, /// Previous sprint id. from: Option, /// New sprint id. to: Option, /// New optimistic version. version: u64, }, /// 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, /// Which entry door the request arrived through (`"file"` watcher vs /// `"mcp"` server). Serialised as a lowercase string so the frontend can /// badge the source. source: OrchestrationSourceDto, }, /// The project's orchestrator designation changed (T1). #[serde(rename_all = "camelCase")] OrchestratorChanged { /// The project whose designation changed. project_id: String, /// The newly designated orchestrator agent, or `None` for the default /// (the oldest agent orchestrates). #[serde(skip_serializing_if = "Option::is_none")] orchestrator: Option, }, /// 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, }, /// An agent entered a **session/rate limit** (ARCHITECTURE §21). Low-frequency, /// model-agnostic badge: carries only the neutral "limited, maybe resets at T" /// fact. The frontend badges "limité jusqu'à HH:MM". #[serde(rename_all = "camelCase")] AgentRateLimited { /// Agent id (UUID string). agent_id: String, /// Reset instant in **epoch-milliseconds**; `null` ⇒ unknown (no auto-resume). #[serde(skip_serializing_if = "Option::is_none")] resets_at_ms: Option, }, /// An **auto-resume** was armed for a rate-limited agent (ARCHITECTURE §21). The /// frontend shows the countdown + the "Annuler la reprise" button (cancellable /// window). #[serde(rename_all = "camelCase")] AgentResumeScheduled { /// Agent id (UUID string). agent_id: String, /// Wake-up deadline in **epoch-milliseconds**. fire_at_ms: i64, }, /// An agent's **auto-resume** was **cancelled** (ARCHITECTURE §21): the user /// clicked "Annuler la reprise". The frontend removes the countdown. #[serde(rename_all = "camelCase")] AgentResumeCancelled { /// Agent id (UUID string). agent_id: String, }, /// An agent was effectively **resumed** after a limit (ARCHITECTURE §21): the /// wake-up fired (or immediate resume). The frontend clears the "limited" state. #[serde(rename_all = "camelCase")] AgentResumed { /// Agent id (UUID string). agent_id: String, }, /// **Human net (level 3)**: a session limit is **suspected** without any reliable /// reset time (ARCHITECTURE §21.1). IdeA never resumes blind: this asks the front /// to **prompt the user** ("limit detected but time unknown — resume at?"). #[serde(rename_all = "camelCase")] AgentRateLimitSuspected { /// Agent id (UUID string). agent_id: String, /// Reset instant in **epoch-milliseconds** if an estimate exists, else `null`. #[serde(skip_serializing_if = "Option::is_none")] resets_at_ms: Option, }, /// 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, }, } /// Wire mirror of [`OrchestrationSource`]: which entry door a processed /// orchestration request arrived through. Serialised as a lowercase string /// (`"file"` / `"mcp"`) the frontend badges on the event. #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub enum OrchestrationSourceDto { /// A `.ideai/requests` JSON file (filesystem watcher). File, /// A `tools/call` on the MCP server. Mcp, } /// Wire mirror of [`AgentLiveness`]: the alive/stalled liveness of an agent (lot 2), /// serialised as a lowercase string (`"alive"` / `"stalled"`) the frontend badges. #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub enum AgentLivenessDto { /// The agent shows proof of liveness (or is idle). Alive, /// No proof of liveness past the profile's `stallAfterMs` threshold. Stalled, } impl From for AgentLivenessDto { fn from(liveness: AgentLiveness) -> Self { match liveness { AgentLiveness::Alive => Self::Alive, AgentLiveness::Stalled => Self::Stalled, } } } impl From for OrchestrationSourceDto { fn from(source: OrchestrationSource) -> Self { match source { OrchestrationSource::File => Self::File, OrchestrationSource::Mcp => Self::Mcp, } } } fn issue_status_wire(status: IssueStatus) -> &'static str { match status { IssueStatus::Open => "open", IssueStatus::InProgress => "inProgress", IssueStatus::Qa => "QA", IssueStatus::Closed => "closed", } } fn issue_priority_wire(priority: IssuePriority) -> &'static str { match priority { IssuePriority::Low => "low", IssuePriority::Medium => "medium", IssuePriority::High => "high", IssuePriority::Critical => "critical", } } fn issue_link_kind_wire(kind: IssueLinkKind) -> &'static str { match kind { IssueLinkKind::RelatesTo => "relatesTo", IssueLinkKind::Blocks => "blocks", IssueLinkKind::BlockedBy => "blockedBy", IssueLinkKind::Duplicates => "duplicates", IssueLinkKind::DependsOn => "dependsOn", } } fn conversation_party_wire(party: ConversationParty) -> String { match party { ConversationParty::User => "user".to_owned(), ConversationParty::Agent { agent_id } => agent_id.to_string(), } } 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::TicketAssistantOpened { issue_ref, profile_id, } => Self::TicketAssistantOpened { issue_ref: issue_ref.to_string(), profile_id: profile_id.to_string(), }, DomainEvent::TicketAssistantClosed { issue_ref } => Self::TicketAssistantClosed { issue_ref: issue_ref.to_string(), }, DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged { agent_id: agent_id.to_string(), busy: *busy, }, DomainEvent::DelegationReady { agent_id, ticket, text, submit_sequence, submit_delay_ms, } => Self::DelegationReady { agent_id: agent_id.to_string(), ticket: ticket.to_string(), text: text.clone(), submit_sequence: submit_sequence.clone(), submit_delay_ms: *submit_delay_ms, }, DomainEvent::AgentReplied { agent_id, reply_len, } => Self::AgentReplied { agent_id: agent_id.to_string(), reply_len: *reply_len, }, DomainEvent::AgentAnnouncement { project_id, requester, target, ticket, text, at_ms, } => Self::AgentAnnouncement { project_id: project_id.to_string(), requester: conversation_party_wire(*requester), target: target.to_string(), ticket_id: ticket.to_string(), text: text.clone(), at_ms: *at_ms, }, DomainEvent::AgentLivenessChanged { agent_id, liveness } => { Self::AgentLivenessChanged { agent_id: agent_id.to_string(), liveness: (*liveness).into(), } } DomainEvent::BackgroundTaskStarted { project_id, task_id, owner_agent_id, } => Self::BackgroundTaskChanged { project_id: project_id.to_string(), task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "started".to_owned(), }, DomainEvent::BackgroundTaskStateChanged { project_id, task_id, owner_agent_id, state, } => Self::BackgroundTaskChanged { project_id: project_id.to_string(), task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: format!("{state:?}"), }, DomainEvent::BackgroundTaskCompleted { project_id, task_id, owner_agent_id, } => Self::BackgroundTaskChanged { project_id: project_id.to_string(), task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "completed".to_owned(), }, DomainEvent::BackgroundTaskFailed { project_id, task_id, owner_agent_id, } => Self::BackgroundTaskChanged { project_id: project_id.to_string(), task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "failed".to_owned(), }, DomainEvent::BackgroundTaskCancelled { project_id, task_id, owner_agent_id, } => Self::BackgroundTaskChanged { project_id: project_id.to_string(), task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "cancelled".to_owned(), }, DomainEvent::BackgroundTaskCompletionDeliveryPending { project_id, task_id, owner_agent_id, } => Self::BackgroundTaskChanged { project_id: project_id.to_string(), task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "deliveryPending".to_owned(), }, DomainEvent::BackgroundTaskCompletionDelivered { project_id, task_id, owner_agent_id, } => Self::BackgroundTaskChanged { project_id: project_id.to_string(), task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "delivered".to_owned(), }, DomainEvent::AgentInboxQueued { agent_id, depth } => Self::AgentInboxChanged { agent_id: agent_id.to_string(), depth: *depth, action: "queued".to_owned(), }, DomainEvent::AgentInboxDrained { agent_id, depth } => Self::AgentInboxChanged { agent_id: agent_id.to_string(), depth: *depth, action: "drained".to_owned(), }, DomainEvent::AgentWakeScheduled { project_id, agent_id, } => Self::AgentWakeChanged { project_id: project_id.to_string(), agent_id: agent_id.to_string(), action: "scheduled".to_owned(), reason: None, }, DomainEvent::AgentWakeStarted { project_id, agent_id, } => Self::AgentWakeChanged { project_id: project_id.to_string(), agent_id: agent_id.to_string(), action: "started".to_owned(), reason: None, }, DomainEvent::AgentWakeFailed { project_id, agent_id, reason, } => Self::AgentWakeChanged { project_id: project_id.to_string(), agent_id: agent_id.to_string(), action: "failed".to_owned(), reason: Some(reason.clone()), }, DomainEvent::AgentProfileChanged { agent_id, profile_id, } => Self::AgentProfileChanged { agent_id: agent_id.to_string(), profile_id: profile_id.to_string(), }, 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::IssueCreated { issue_id, issue_ref, } => Self::IssueCreated { issue_id: issue_id.to_string(), issue_ref: issue_ref.to_string(), }, DomainEvent::IssueUpdated { issue_ref, version } => Self::IssueUpdated { issue_ref: issue_ref.to_string(), version: version.get(), }, DomainEvent::IssueDeleted { project_id, issue_ref, freed_sprint, } => Self::IssueDeleted { project_id: project_id.to_string(), issue_ref: issue_ref.to_string(), freed_sprint: freed_sprint.map(|sprint| sprint.to_string()), }, DomainEvent::IssueStatusChanged { issue_ref, status, version, } => Self::IssueStatusChanged { issue_ref: issue_ref.to_string(), status: issue_status_wire(*status).to_owned(), version: version.get(), }, DomainEvent::IssuePriorityChanged { issue_ref, priority, version, } => Self::IssuePriorityChanged { issue_ref: issue_ref.to_string(), priority: issue_priority_wire(*priority).to_owned(), version: version.get(), }, DomainEvent::IssueCarnetUpdated { issue_ref, version } => Self::IssueCarnetUpdated { issue_ref: issue_ref.to_string(), version: version.get(), }, DomainEvent::IssueLinked { issue_ref, target, kind, version, } => Self::IssueLinked { issue_ref: issue_ref.to_string(), target: target.to_string(), kind: issue_link_kind_wire(*kind).to_owned(), version: version.get(), }, DomainEvent::IssueUnlinked { issue_ref, target, kind, version, } => Self::IssueUnlinked { issue_ref: issue_ref.to_string(), target: target.to_string(), kind: issue_link_kind_wire(*kind).to_owned(), version: version.get(), }, DomainEvent::IssueAgentAssigned { issue_ref, agent_id, version, } => Self::IssueAgentAssigned { issue_ref: issue_ref.to_string(), agent_id: agent_id.to_string(), version: version.get(), }, DomainEvent::IssueAgentUnassigned { issue_ref, agent_id, version, } => Self::IssueAgentUnassigned { issue_ref: issue_ref.to_string(), agent_id: agent_id.to_string(), version: version.get(), }, DomainEvent::SprintCreated { sprint_id, order } => Self::SprintCreated { sprint_id: sprint_id.to_string(), order: order.get(), }, DomainEvent::SprintRenamed { sprint_id, name, version, } => Self::SprintRenamed { sprint_id: sprint_id.to_string(), name: name.clone(), version: version.get(), }, DomainEvent::SprintReordered { sprint_id, order, version, } => Self::SprintReordered { sprint_id: sprint_id.to_string(), order: order.get(), version: version.get(), }, DomainEvent::SprintDeleted { sprint_id } => Self::SprintDeleted { sprint_id: sprint_id.to_string(), }, DomainEvent::IssueSprintChanged { issue_ref, from, to, version, } => Self::IssueSprintChanged { issue_ref: issue_ref.to_string(), from: from.map(|id| id.to_string()), to: to.map(|id| id.to_string()), version: version.get(), }, DomainEvent::OrchestratorRequestProcessed { requester_id, action, ok, source, } => Self::OrchestratorRequestProcessed { requester_id: requester_id.clone(), action: action.clone(), ok: *ok, source: (*source).into(), }, DomainEvent::OrchestratorChanged { project_id, orchestrator, } => Self::OrchestratorChanged { project_id: project_id.to_string(), orchestrator: orchestrator.as_ref().map(|a| a.to_string()), }, 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::AgentRateLimited { agent_id, resets_at_ms, } => Self::AgentRateLimited { agent_id: agent_id.to_string(), resets_at_ms: *resets_at_ms, }, DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms, } => Self::AgentResumeScheduled { agent_id: agent_id.to_string(), fire_at_ms: *fire_at_ms, }, DomainEvent::AgentResumeCancelled { agent_id } => Self::AgentResumeCancelled { agent_id: agent_id.to_string(), }, DomainEvent::AgentResumed { agent_id } => Self::AgentResumed { agent_id: agent_id.to_string(), }, DomainEvent::AgentRateLimitSuspected { agent_id, resets_at_ms, } => Self::AgentRateLimitSuspected { agent_id: agent_id.to_string(), resets_at_ms: *resets_at_ms, }, 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, } } }); } #[cfg(test)] mod tests { use super::*; use domain::ids::AgentId; use domain::mailbox::TicketId; use domain::ProjectId; use serde_json::json; fn agent(n: u128) -> AgentId { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } /// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO /// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que /// le front badge). Garantit le câblage présentation de la détection de stall. #[test] fn liveness_changed_stalled_relays_to_dto_and_wire() { let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged { agent_id: agent(7), liveness: AgentLiveness::Stalled, }); let json = serde_json::to_value(&dto).expect("serialisable"); assert_eq!(json["type"], "agentLivenessChanged"); assert_eq!(json["agentId"], agent(7).to_string()); assert_eq!(json["liveness"], "stalled"); } /// La reprise `Stalled→Alive` se relaie en DTO `Alive` ⇒ wire `"alive"`. #[test] fn liveness_changed_alive_relays_to_dto_and_wire() { let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged { agent_id: agent(3), liveness: AgentLiveness::Alive, }); let json = serde_json::to_value(&dto).expect("serialisable"); assert_eq!(json["type"], "agentLivenessChanged"); assert_eq!(json["liveness"], "alive"); } #[test] fn agent_announcement_relays_to_camel_case_wire_with_requester_party() { let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1)); let requester_agent = agent(2); let target = agent(3); let ticket = TicketId::from_uuid(uuid::Uuid::from_u128(4)); let human = DomainEventDto::from(&DomainEvent::AgentAnnouncement { project_id, requester: ConversationParty::User, target, ticket, text: "statut humain".into(), at_ms: 123_456, }); assert_eq!( serde_json::to_value(&human).unwrap(), json!({ "type": "agentAnnouncement", "projectId": project_id.to_string(), "requester": "user", "target": target.to_string(), "ticketId": ticket.to_string(), "text": "statut humain", "atMs": 123456, }) ); let agent_requester = DomainEventDto::from(&DomainEvent::AgentAnnouncement { project_id, requester: ConversationParty::agent(requester_agent), target, ticket, text: "statut agent".into(), at_ms: 654_321, }); assert_eq!( serde_json::to_value(&agent_requester).unwrap(), json!({ "type": "agentAnnouncement", "projectId": project_id.to_string(), "requester": requester_agent.to_string(), "target": target.to_string(), "ticketId": ticket.to_string(), "text": "statut agent", "atMs": 654321, }) ); } /// LS6 : un `AgentRateLimited` du domaine se relaie en DTO portant le même agent /// et l'heure de reset (époche-ms), et se sérialise en `"agentRateLimited"` avec /// `resetsAtMs` — le fait neutre que le front badge « limité jusqu'à HH:MM ». #[test] fn rate_limited_relays_to_dto_and_wire() { let dto = DomainEventDto::from(&DomainEvent::AgentRateLimited { agent_id: agent(11), resets_at_ms: Some(1_700_000_000_000), }); let json = serde_json::to_value(&dto).expect("serialisable"); assert_eq!(json["type"], "agentRateLimited"); assert_eq!(json["agentId"], agent(11).to_string()); assert_eq!(json["resetsAtMs"], 1_700_000_000_000_i64); } #[test] fn issue_sprint_changed_relays_to_dto_and_wire() { let from = domain::SprintId::from_uuid(uuid::Uuid::from_u128(41)); let to = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42)); let dto = DomainEventDto::from(&DomainEvent::IssueSprintChanged { issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), from: Some(from), to: Some(to), version: domain::IssueVersion::new(3).unwrap(), }); let json = serde_json::to_value(&dto).expect("serialisable"); assert_eq!(json["type"], "issueSprintChanged"); assert_eq!(json["issueRef"], "#7"); assert_eq!(json["from"], from.to_string()); assert_eq!(json["to"], to.to_string()); assert_eq!(json["version"], 3); } #[test] fn issue_deleted_relays_to_dto_and_wire() { let project_id = domain::ProjectId::from_uuid(uuid::Uuid::from_u128(40)); let sprint_id = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42)); let dto = DomainEventDto::from(&DomainEvent::IssueDeleted { project_id, issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), freed_sprint: Some(sprint_id), }); let json = serde_json::to_value(&dto).expect("serialisable"); assert_eq!(json["type"], "issueDeleted"); assert_eq!(json["projectId"], project_id.to_string()); assert_eq!(json["issueRef"], "#7"); assert_eq!(json["freedSprint"], sprint_id.to_string()); } #[test] fn ticket_assistant_events_relay_to_dto_and_wire() { let profile_id = domain::ProfileId::from_uuid(uuid::Uuid::from_u128(9)); let opened = DomainEventDto::from(&DomainEvent::TicketAssistantOpened { issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), profile_id, }); let opened = serde_json::to_value(&opened).expect("serialisable"); assert_eq!(opened["type"], "ticketAssistantOpened"); assert_eq!(opened["issueRef"], "#7"); assert_eq!(opened["profileId"], profile_id.to_string()); let closed = DomainEventDto::from(&DomainEvent::TicketAssistantClosed { issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), }); let closed = serde_json::to_value(&closed).expect("serialisable"); assert_eq!(closed["type"], "ticketAssistantClosed"); assert_eq!(closed["issueRef"], "#7"); } }