Expose l'orchestration IdeA comme serveur MCP par-dessus le même OrchestratorService::dispatch, avec repli fichier .ideai/requests pour les CLI sans MCP. v3 réduite à la surface MCP : la messagerie inter-agents et la corrélation requête↔réponse étaient déjà résolues par §17 (send_blocking). - M0 capacité MCP sur le profil (McpCapability/McpConfigStrategy/McpTransport) - M1 injection conf MCP au LaunchAgent + prose adaptée selon la surface - M2 serveur/adapter MCP (JSON-RPC 2.0 maison ; outils idea_*) + ListAgents - M3 câblage par projet (registre mcp_servers jumeau du watcher) - M4 observabilité UI : OrchestratorRequestProcessed.source = file|mcp + badge Trois portes d'entrée (fichier, MCP, UI) → un seul dispatch ; aucun nouveau port applicatif ; MCP confiné à l'adapter infra. Tous lots verts (cycle §3). Cadrage : .ideai/briefs/orchestration-v3-cadrage.md ; ARCHITECTURE.md §14.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
333 lines
12 KiB
Rust
333 lines
12 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 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 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 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,
|
|
}
|
|
|
|
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::AgentReplied {
|
|
agent_id,
|
|
reply_len,
|
|
} => Self::AgentReplied {
|
|
agent_id: agent_id.to_string(),
|
|
reply_len: *reply_len,
|
|
},
|
|
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,
|
|
}
|
|
}
|
|
});
|
|
}
|