feat(orchestrator): file-based orchestrator request watcher (§14.3)

- domain: OrchestratorRequest/Command parse-don't-validate + OrchestratorRequestProcessed event
- application: OrchestratorService dispatching spawn/stop/update_agent_context
- infrastructure: request watcher over .ideai/requests/, writes .response.json
- app-tauri: relay OrchestratorRequestProcessed to the frontend DTO

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:12:04 +02:00
parent 9736c42424
commit 480e7c7bbe
14 changed files with 1908 additions and 3 deletions

View File

@ -16,6 +16,7 @@ pub mod error;
pub mod git;
pub mod health;
pub mod layout;
pub mod orchestrator;
pub mod project;
pub mod remote;
pub mod template;
@ -33,6 +34,7 @@ pub use agent::{
SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput,
};
pub use error::AppError;
pub use orchestrator::{OrchestratorOutcome, OrchestratorService};
pub use git::{
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput,

View File

@ -0,0 +1,9 @@
//! Orchestrator application service (ARCHITECTURE §14.3).
//!
//! Turns a validated [`domain::OrchestratorCommand`] into the *same* agent/terminal
//! use-case calls the UI makes, so an orchestrator agent can drive IdeA without
//! ever spawning a process itself. See [`service::OrchestratorService`].
mod service;
pub use service::{OrchestratorOutcome, OrchestratorService};

View File

@ -0,0 +1,284 @@
//! [`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 domain::ports::ProfileStore;
use domain::{OrchestratorCommand, Project, ProfileId};
use crate::agent::{
CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents,
ListAgentsInput, UpdateAgentContext, UpdateAgentContextInput,
};
use crate::error::AppError;
use crate::terminal::{CloseTerminal, CloseTerminalInput, 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;
/// Dispatches validated orchestrator commands to the agent/terminal use cases.
pub struct OrchestratorService {
create_agent: Arc<CreateAgentFromScratch>,
launch_agent: Arc<LaunchAgent>,
list_agents: Arc<ListAgents>,
close_terminal: Arc<CloseTerminal>,
update_context: Arc<UpdateAgentContext>,
profiles: Arc<dyn ProfileStore>,
sessions: Arc<TerminalSessions>,
}
/// 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,
}
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<CreateAgentFromScratch>,
launch_agent: Arc<LaunchAgent>,
list_agents: Arc<ListAgents>,
close_terminal: Arc<CloseTerminal>,
update_context: Arc<UpdateAgentContext>,
profiles: Arc<dyn ProfileStore>,
sessions: Arc<TerminalSessions>,
) -> Self {
Self {
create_agent,
launch_agent,
list_agents,
close_terminal,
update_context,
profiles,
sessions,
}
}
/// 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<OrchestratorOutcome, AppError> {
match command {
OrchestratorCommand::SpawnAgent {
name,
profile,
context,
} => self.spawn_agent(project, name, profile, context).await,
OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
OrchestratorCommand::UpdateAgentContext { name, context } => {
self.update_agent_context(project, name, context).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: String,
context: Option<String>,
) -> Result<OrchestratorOutcome, AppError> {
let existing = self.find_agent_id_by_name(project, &name).await?;
let agent_id = match existing {
Some(id) => id,
None => {
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
}
};
self.launch_agent
.execute(LaunchAgentInput {
project: project.clone(),
agent_id,
rows: DEFAULT_ROWS,
cols: DEFAULT_COLS,
node_id: None,
})
.await?;
Ok(OrchestratorOutcome {
detail: format!("launched agent {name}"),
})
}
/// `stop_agent`: translate the agent name → its live session → `CloseTerminal`.
async fn stop_agent(
&self,
project: &Project,
name: String,
) -> Result<OrchestratorOutcome, AppError> {
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}"),
})
}
/// `update_agent_context`: overwrite the agent's `.md` body.
async fn update_agent_context(
&self,
project: &Project,
name: String,
context: String,
) -> Result<OrchestratorOutcome, AppError> {
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}"),
})
}
/// 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<Option<domain::AgentId>, 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<ProfileId, AppError> {
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}",
)
.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());
}
}

View File

@ -10,7 +10,7 @@ use std::collections::HashMap;
use std::sync::Mutex;
use domain::ports::PtyHandle;
use domain::{SessionId, TerminalSession};
use domain::{AgentId, SessionId, SessionKind, TerminalSession};
/// A registered, live terminal: its PTY handle plus the domain snapshot.
#[derive(Debug, Clone)]
@ -59,6 +59,21 @@ impl TerminalSessions {
.and_then(|m| m.get(id).map(|e| e.session.clone()))
}
/// Returns the [`SessionId`] of the live session hosting a given agent, if any.
///
/// An agent runs in a session tagged [`SessionKind::Agent`]; this is the
/// mapping the orchestrator's `stop_agent` uses to translate an agent id into
/// the [`SessionId`] that `CloseTerminal` expects. Returns `None` when the
/// agent has no live session (already stopped / never launched).
#[must_use]
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
self.entries.lock().ok().and_then(|m| {
m.values()
.find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
.map(|e| e.session.id)
})
}
/// Returns the [`PtyHandle`]s of every currently-registered session.
///
/// Used at application shutdown to kill all live PTYs cleanly (the