//! [`OrchestratorService`] — dispatches a validated [`OrchestratorCommand`] to the //! existing agent/terminal use cases (ARCHITECTURE §14.3). //! //! The orchestrator agent never spawns a process itself: IdeA is the single source //! of truth for the agent lifecycle. This service is the application-layer seam //! that turns a request into the *same* calls the UI makes: //! //! - `spawn_agent` → [`CreateAgentFromScratch`] (if unknown) then [`LaunchAgent`], //! - `stop_agent` → resolve the agent's live session, then [`CloseTerminal`], //! - `update_agent_context` → [`UpdateAgentContext`]. //! //! It talks **only** to use cases and ports ([`ProfileStore`], [`TerminalSessions`]): //! no filesystem watching, no JSON, no process spawning here — those are the //! infrastructure adapter's job. That keeps this fully unit-testable with fakes. use std::sync::Arc; use std::time::Duration; use domain::ports::{EventBus, ProfileStore}; use domain::{DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project}; use crate::agent::{ send_blocking, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, UpdateAgentContext, UpdateAgentContextInput, }; use crate::error::AppError; use crate::skill::{CreateSkill, CreateSkillInput}; use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions}; /// Default terminal geometry for an orchestrator-launched agent cell. The UI /// resizes the PTY to the real cell size on attach; these are sane starting rows /// /cols so the spawn never fails on a zero-sized terminal. const DEFAULT_ROWS: u16 = 24; /// See [`DEFAULT_ROWS`]. const DEFAULT_COLS: u16 = 80; /// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`). /// /// A target agent's turn can be long (reasoning + tool use), so the cap is /// generous; on expiry [`send_blocking`] returns [`domain::ports::AgentSessionError::Timeout`] /// **without killing the session**, so the requester can retry. Internal and /// intentionally not yet config-exposed (it may become a per-project setting /// without changing the contract). const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300); /// Dispatches validated orchestrator commands to the agent/terminal use cases. pub struct OrchestratorService { create_agent: Arc, launch_agent: Arc, list_agents: Arc, close_terminal: Arc, update_context: Arc, create_skill: Arc, profiles: Arc, sessions: Arc, /// Registre des sessions **structurées** (§17.5) — la cible d'un `agent.message` /// y est cherchée pour le rendez-vous synchrone. Injecté au câblage via /// [`Self::with_structured`] ; `None` ⇒ `AskAgent` ne peut pas être servi (les /// call sites/tests legacy qui n'utilisent pas la messagerie restent verts). structured: Option>, /// Bus d'événements pour publier [`DomainEvent::AgentReplied`] à l'issue d'un /// `ask` réussi (§17.4). Injecté via [`Self::with_events`] ; `None` ⇒ pas de /// publication (l'`ask` fonctionne quand même). events: Option>, } /// Outcome of dispatching a command — a short, human-readable success summary the /// infrastructure adapter folds into the JSON response file. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OrchestratorOutcome { /// One-line description of what IdeA did (e.g. `"launched agent dev-backend"`). pub detail: String, /// The target agent's **reply content** for a synchronous `agent.message` /// (`AskAgent`, §17.4). `Some(content)` carries the turn's `Final`; every other /// command leaves it `None`. Additive field ⇒ existing call sites/tests are /// untouched (they only read [`Self::detail`]). pub reply: Option, } impl OrchestratorService { /// Builds the service from the use cases and ports it dispatches to. #[must_use] #[allow(clippy::too_many_arguments)] pub fn new( create_agent: Arc, launch_agent: Arc, list_agents: Arc, close_terminal: Arc, update_context: Arc, create_skill: Arc, profiles: Arc, sessions: Arc, ) -> Self { Self { create_agent, launch_agent, list_agents, close_terminal, update_context, create_skill, profiles, sessions, structured: None, events: None, } } /// Branche le registre des sessions **structurées** (§17.5) pour servir /// `agent.message`/[`OrchestratorCommand::AskAgent`]. Builder additif façon D3 : /// signature de [`Self::new`] **inchangée** (les tests/call sites legacy restent /// verts), le câblage fait `OrchestratorService::new(...).with_structured(reg)`. #[must_use] pub fn with_structured(mut self, structured: Arc) -> Self { self.structured = Some(structured); self } /// Branche l'[`EventBus`] pour publier [`DomainEvent::AgentReplied`] après un /// `ask` réussi (§17.4). Builder additif (cf. [`Self::with_structured`]). #[must_use] pub fn with_events(mut self, events: Arc) -> Self { self.events = Some(events); self } /// Dispatches a validated command against `project`. /// /// # Errors /// Propagates the underlying use-case [`AppError`] (e.g. unknown profile, /// unknown agent, PTY failure). For `spawn_agent` a *known* agent is launched /// directly; an *unknown* one is created from scratch first. pub async fn dispatch( &self, project: &Project, command: OrchestratorCommand, ) -> Result { match command { OrchestratorCommand::SpawnAgent { name, profile, context, visibility, } => { self.spawn_agent(project, name, profile, context, visibility) .await } OrchestratorCommand::AskAgent { target, task } => { self.ask_agent(project, target, task).await } OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await, OrchestratorCommand::UpdateAgentContext { name, context } => { self.update_agent_context(project, name, context).await } OrchestratorCommand::CreateSkill { name, content, scope, } => self.create_skill(project, name, content, scope).await, } } /// `spawn_agent`: create the agent if the manifest doesn't already hold one by /// that name, then launch it (which publishes `AgentLaunched` → the UI opens a /// cell + the Agents tab). async fn spawn_agent( &self, project: &Project, name: String, profile: Option, context: Option, visibility: OrchestratorVisibility, ) -> Result { let existing = self.find_agent_id_by_name(project, &name).await?; let agent_id = match existing { Some(id) => id, None => { let profile = profile.as_deref().ok_or_else(|| { AppError::Invalid("profile is required to create an agent".to_owned()) })?; let profile_id = self.resolve_profile(profile).await?; let created = self .create_agent .execute(CreateAgentInput { project: project.clone(), name: name.clone(), profile_id, initial_content: context, }) .await?; created.agent.id } }; if let Some(session_id) = self.sessions.session_for_agent(&agent_id) { match visibility { OrchestratorVisibility::Background => { return Ok(OrchestratorOutcome { detail: format!("agent {name} already running in background"), reply: None, }); } OrchestratorVisibility::Visible { node_id } => { let session = self .sessions .rebind_agent_node(&agent_id, node_id) .ok_or_else(|| { AppError::NotFound(format!( "running session {session_id} for agent {name}" )) })?; return Ok(OrchestratorOutcome { detail: format!("attached agent {name} to cell {}", session.node_id), reply: None, }); } } } let node_id = match visibility { OrchestratorVisibility::Background => None, OrchestratorVisibility::Visible { node_id } => Some(node_id), }; self.launch_agent .execute(LaunchAgentInput { project: project.clone(), agent_id, rows: DEFAULT_ROWS, cols: DEFAULT_COLS, node_id, conversation_id: None, }) .await?; Ok(OrchestratorOutcome { detail: match visibility { OrchestratorVisibility::Background => { format!("launched agent {name} in background") } OrchestratorVisibility::Visible { node_id } => { format!("launched agent {name} in cell {node_id}") } }, reply: None, }) } /// `agent.message`: the **synchronous inter-agent rendezvous** (§17.4). /// /// Resolves the target by name, ensures it has a **live structured session** /// (launching it in structured mode if needed), sends `task` and **waits for the /// turn's `Final`** via [`send_blocking`], then returns its content as /// [`OrchestratorOutcome::reply`] and publishes [`DomainEvent::AgentReplied`]. /// /// Invariants: /// - **1 session vivante/agent** : on réutilise la session structurée vivante si /// elle existe ; sinon `LaunchAgent` (gardé sur les deux registres) la crée. /// - **Timeout ne tue pas la session** : [`send_blocking`] remonte /// [`AppError::Process`] (via [`domain::ports::AgentSessionError::Timeout`]) en /// laissant la session vivante dans le registre (retry possible). /// - **Cible PTY-only** (profil sans `structured_adapter`, ou agent déjà vivant en /// PTY) ⇒ [`AppError::Invalid`] explicite, **jamais** un ACK trompeur. /// /// # Errors /// - [`AppError::NotFound`] si l'agent cible est inconnu ; /// - [`AppError::Invalid`] si la messagerie structurée n'est pas câblée, ou si la /// cible n'est pas pilotable en mode structuré ; /// - [`AppError::Process`] sur échec/timeout du tour structuré. async fn ask_agent( &self, project: &Project, target: String, task: String, ) -> Result { let structured = self.structured.as_ref().ok_or_else(|| { AppError::Invalid( "la messagerie inter-agents (agent.message) n'est pas disponible : \ registre des sessions structurées non câblé" .to_owned(), ) })?; let agent_id = self .find_agent_id_by_name(project, &target) .await? .ok_or_else(|| AppError::NotFound(format!("agent {target}")))?; // 1. Session structurée déjà vivante ? ⇒ rendez-vous direct. if let Some(session) = structured.session_for_agent(&agent_id) { let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?; return Ok(self.reply_outcome(agent_id, &target, content)); } // 2. Pas de session structurée. Si l'agent est vivant en **PTY** (terminal // brut), il n'est pas adressable en `ask` ⇒ erreur typée explicite (pas // d'ACK « launched »). if self.sessions.session_for_agent(&agent_id).is_some() { return Err(AppError::Invalid(format!( "agent {target} n'est pas pilotable en mode structuré \ (session terminal brut, pas de canal de réponse)" ))); } // 3. Cible morte : on la lance en mode structuré (background) puis on envoie. let launched = self .launch_agent .execute(LaunchAgentInput { project: project.clone(), agent_id, rows: DEFAULT_ROWS, cols: DEFAULT_COLS, node_id: None, conversation_id: None, }) .await?; // Si le lancement n'a pas produit de session structurée, le profil de la cible // est PTY-only (pas de `structured_adapter`) ⇒ non adressable en `ask`. if launched.structured.is_none() { return Err(AppError::Invalid(format!( "agent {target} n'est pas pilotable en mode structuré \ (profil sans adaptateur structuré)" ))); } // La session vient d'être enregistrée par LaunchAgent : on la récupère par // agent (invariant « 1 session/agent » ⇒ non ambigu). let session = structured.session_for_agent(&agent_id).ok_or_else(|| { AppError::Process(format!( "session structurée de l'agent {target} introuvable après lancement" )) })?; let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?; Ok(self.reply_outcome(agent_id, &target, content)) } /// Builds the success outcome of an `ask` and publishes [`DomainEvent::AgentReplied`] /// (best-effort: a missing event bus never fails the rendezvous). fn reply_outcome( &self, agent_id: domain::AgentId, target: &str, content: String, ) -> OrchestratorOutcome { if let Some(events) = &self.events { events.publish(DomainEvent::AgentReplied { agent_id, reply_len: content.len(), }); } OrchestratorOutcome { detail: format!("agent {target} replied ({} bytes)", content.len()), reply: Some(content), } } /// `stop_agent`: translate the agent name → its live session → `CloseTerminal`. async fn stop_agent( &self, project: &Project, name: String, ) -> Result { let agent_id = self .find_agent_id_by_name(project, &name) .await? .ok_or_else(|| AppError::NotFound(format!("agent {name}")))?; let session_id = self .sessions .session_for_agent(&agent_id) .ok_or_else(|| AppError::NotFound(format!("running session for agent {name}")))?; self.close_terminal .execute(CloseTerminalInput { session_id }) .await?; Ok(OrchestratorOutcome { detail: format!("stopped agent {name}"), reply: None, }) } /// `update_agent_context`: overwrite the agent's `.md` body. async fn update_agent_context( &self, project: &Project, name: String, context: String, ) -> Result { let agent_id = self .find_agent_id_by_name(project, &name) .await? .ok_or_else(|| AppError::NotFound(format!("agent {name}")))?; self.update_context .execute(UpdateAgentContextInput { project: project.clone(), agent_id, content: context, }) .await?; Ok(OrchestratorOutcome { detail: format!("updated context for agent {name}"), reply: None, }) } /// `create_skill`: create a reusable skill in the requested scope — the same /// path the UI's "New skill" action takes. For [`SkillScope::Project`] the /// skill lands under `/.ideai/skills/`; for [`SkillScope::Global`] the /// project root is ignored by the store. async fn create_skill( &self, project: &Project, name: String, content: String, scope: domain::SkillScope, ) -> Result { let created = self .create_skill .execute(CreateSkillInput { name: name.clone(), content, scope, project_root: project.root.clone(), }) .await?; Ok(OrchestratorOutcome { detail: format!("created skill {} ({:?})", created.skill.name, scope), reply: None, }) } /// Finds an agent id by display name (case-insensitive) in the project manifest. async fn find_agent_id_by_name( &self, project: &Project, name: &str, ) -> Result, AppError> { let listed = self .list_agents .execute(ListAgentsInput { project: project.clone(), }) .await?; Ok(listed .agents .into_iter() .find(|a| a.name.eq_ignore_ascii_case(name)) .map(|a| a.id)) } /// Resolves a human-friendly profile reference (slug like `claude-code`, /// command like `claude`, or display name like `Claude Code`) to a configured /// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by /// scanning the configured profiles' command and name. /// /// # Errors /// [`AppError::NotFound`] when no configured profile matches. async fn resolve_profile(&self, reference: &str) -> Result { let needle = normalise(reference); let profiles = self.profiles.list().await?; profiles .into_iter() .find(|p| { normalise(&p.command) == needle || normalise(&p.name) == needle || p.id.to_string() == reference }) .map(|p| p.id) .ok_or_else(|| AppError::NotFound(format!("profile matching '{reference}'"))) } } /// Normalises a profile reference for tolerant matching: lowercased, with spaces, /// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"` /// → comparable forms; `claude` ⊂ ... handled by the command match above). fn normalise(s: &str) -> String { s.chars() .filter(|c| c.is_ascii_alphanumeric()) .map(|c| c.to_ascii_lowercase()) .collect() } #[cfg(test)] mod tests { use super::*; use domain::profile::{AgentProfile, ContextInjection}; use domain::ProfileId; fn profile(id: u128, name: &str, command: &str) -> AgentProfile { AgentProfile::new( ProfileId::from_uuid(uuid::Uuid::from_u128(id)), name, command, Vec::new(), ContextInjection::convention_file("CLAUDE.md").unwrap(), None, "{agentRunDir}", None, ) .unwrap() } #[test] fn normalise_makes_slug_command_and_name_comparable() { assert_eq!(normalise("Claude Code"), "claudecode"); assert_eq!(normalise("claude-code"), "claudecode"); assert_eq!(normalise("claude_code"), "claudecode"); } #[test] fn resolve_matches_by_command_name_or_id() { // We exercise the pure matching predicate the same way `resolve_profile` // does, without standing up the whole service/ports. let p = profile(1, "Claude Code", "claude"); let by_command = normalise("claude") == normalise(&p.command); let by_name = normalise("claude-code") == normalise(&p.name); assert!(by_command); assert!(by_name); assert_eq!(p.id.to_string(), p.id.to_string()); } }