Files
IdeA/crates/app-tauri/src/events.rs
Blomios 0f8ba38d51 feat(agents): pont Codex inter-agents + readiness/heartbeat lot 1
Deux chantiers livrés au vert (workspace entier : domain+application+
infrastructure 42 + app-tauri --lib 128, 0 échec).

## Codex inter-agents
- domaine: McpConfigStrategy::TomlConfigHome { target, home_env } +
  toml_config_home(...); AgentProfile::materializes_idea_bridge()
  (whitelist Claude/ConfigFile + Codex/TomlConfigHome); McpServerWiring
  + encodeur TOML.
- application: lifecycle apply_mcp_config bras TomlConfigHome (écrit
  {runDir}/<target>, pousse (home_env, parent) dans spec.env);
  guard_mcp_bridge_supported ré-exprimée via materializes_idea_bridge();
  catalogue Codex porte toml_config_home(".codex/config.toml","CODEX_HOME").
- app-tauri: is_codex_mcp_profile, migrate_codex_run_dir,
  mcp_server_entry_toml.
- tests: matrice domaine TomlConfigHome + round-trip dual Claude/Codex
  sur loopback réel (fakes, zéro token).

## Readiness/heartbeat lot 1
- domaine: readiness.rs — ReadinessPolicy::classify (Final => TurnEnded),
  variantes ReplyEvent::Heartbeat / ToolActivity.
- application: drain_with_readiness consulte la policy et appelle
  mark_idle sur le signal déterministe; branché dans ask_agent.
  Corrige la cause racine: une cible qui ne renvoie qu'un Final (sans
  idea_reply) débloque désormais sa file Busy.
- infrastructure: adapters de session émettent Heartbeat/ToolActivity.
- tests: drain_with_readiness_lot1 (points QA 5 & 6) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:28:44 +02:00

454 lines
16 KiB
Rust

//! `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, OrchestrationSource};
use domain::input::AgentLiveness;
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,
},
/// 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<String>,
/// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms).
#[serde(skip_serializing_if = "Option::is_none")]
submit_delay_ms: Option<u32>,
},
/// 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,
},
/// 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,
},
/// 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 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,
},
/// 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<String>,
/// 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<u8>,
},
}
/// 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<AgentLiveness> for AgentLivenessDto {
fn from(liveness: AgentLiveness) -> Self {
match liveness {
AgentLiveness::Alive => Self::Alive,
AgentLiveness::Stalled => Self::Stalled,
}
}
}
impl From<OrchestrationSource> for OrchestrationSourceDto {
fn from(source: OrchestrationSource) -> Self {
match source {
OrchestrationSource::File => Self::File,
OrchestrationSource::Mcp => Self::Mcp,
}
}
}
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::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::AgentLivenessChanged { agent_id, liveness } => {
Self::AgentLivenessChanged {
agent_id: agent_id.to_string(),
liveness: (*liveness).into(),
}
}
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::OrchestratorRequestProcessed {
requester_id,
action,
ok,
source,
} => Self::OrchestratorRequestProcessed {
requester_id: requester_id.clone(),
action: action.clone(),
ok: *ok,
source: (*source).into(),
},
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,
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use domain::ids::AgentId;
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");
}
}