Merge branch 'worktree-agent-a2650e91d2bd39ca2' into develop
This commit is contained in:
@ -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 skill;
|
||||
@ -34,6 +35,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,
|
||||
|
||||
9
crates/application/src/orchestrator/mod.rs
Normal file
9
crates/application/src/orchestrator/mod.rs
Normal 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};
|
||||
284
crates/application/src/orchestrator/service.rs
Normal file
284
crates/application/src/orchestrator/service.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
511
crates/application/tests/orchestrator_service.rs
Normal file
511
crates/application/tests/orchestrator_service.rs
Normal file
@ -0,0 +1,511 @@
|
||||
//! Integration tests for [`OrchestratorService`] (ARCHITECTURE §14.3).
|
||||
//!
|
||||
//! The service is wired over the *real* agent/terminal use cases, themselves
|
||||
//! backed by in-memory fakes (the same fake patterns as `agent_lifecycle.rs`).
|
||||
//! This proves the dispatch contract end-to-end without real I/O:
|
||||
//!
|
||||
//! - `spawn_agent` on an **unknown** agent → create + launch (manifest grows, PTY
|
||||
//! spawns, `AgentLaunched` published),
|
||||
//! - `spawn_agent` on a **known** agent → launch only (no second manifest entry),
|
||||
//! - `stop_agent` → the agent's live session is killed and de-registered,
|
||||
//! - `update_agent_context` → the agent `.md` is overwritten,
|
||||
//! - unknown profile / unknown agent → `NotFound`, no spawn.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
|
||||
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
CloseTerminal, CreateAgentFromScratch, LaunchAgent, ListAgents, OrchestratorService,
|
||||
TerminalSessions, UpdateAgentContext,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes (mirror agent_lifecycle.rs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct ContextsInner {
|
||||
manifest: AgentManifest,
|
||||
contents: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeContexts(Arc<Mutex<ContextsInner>>);
|
||||
|
||||
impl FakeContexts {
|
||||
fn new() -> Self {
|
||||
Self(Arc::new(Mutex::new(ContextsInner {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
})))
|
||||
}
|
||||
fn with_agent(agent: &Agent, content: &str) -> Self {
|
||||
let me = Self::new();
|
||||
{
|
||||
let mut inner = me.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(agent));
|
||||
inner
|
||||
.contents
|
||||
.insert(agent.context_path.clone(), content.to_owned());
|
||||
}
|
||||
me
|
||||
}
|
||||
fn manifest(&self) -> AgentManifest {
|
||||
self.0.lock().unwrap().manifest.clone()
|
||||
}
|
||||
fn content(&self, md_path: &str) -> Option<String> {
|
||||
self.0.lock().unwrap().contents.get(md_path).cloned()
|
||||
}
|
||||
fn md_path_of(&self, agent: &AgentId) -> Option<String> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| &e.agent_id == agent)
|
||||
.map(|e| e.md_path.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentContextStore for FakeContexts {
|
||||
async fn read_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
agent: &AgentId,
|
||||
) -> Result<MarkdownDoc, StoreError> {
|
||||
let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
||||
self.content(&md_path)
|
||||
.map(MarkdownDoc::new)
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
async fn write_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
agent: &AgentId,
|
||||
md: &MarkdownDoc,
|
||||
) -> Result<(), StoreError> {
|
||||
let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contents
|
||||
.insert(md_path, md.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
||||
Ok(self.manifest())
|
||||
}
|
||||
async fn save_manifest(
|
||||
&self,
|
||||
_project: &Project,
|
||||
manifest: &AgentManifest,
|
||||
) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().manifest = manifest.clone();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeProfiles(Arc<Vec<AgentProfile>>);
|
||||
impl FakeProfiles {
|
||||
fn new(profiles: Vec<AgentProfile>) -> Self {
|
||||
Self(Arc::new(profiles))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl ProfileStore for FakeProfiles {
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
||||
Ok((*self.0).clone())
|
||||
}
|
||||
async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn is_configured(&self) -> Result<bool, StoreError> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn mark_configured(&self) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeRuntime;
|
||||
impl AgentRuntime for FakeRuntime {
|
||||
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||
Ok(true)
|
||||
}
|
||||
fn prepare_invocation(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
_ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
) -> Result<SpawnSpec, RuntimeError> {
|
||||
Ok(SpawnSpec {
|
||||
command: profile.command.clone(),
|
||||
args: profile.args.clone(),
|
||||
cwd: cwd.clone(),
|
||||
env: Vec::new(),
|
||||
context_plan: Some(ContextInjectionPlan::Stdin),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct FakeFs;
|
||||
#[async_trait]
|
||||
impl FileSystem for FakeFs {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
Err(FsError::NotFound(path.as_str().to_owned()))
|
||||
}
|
||||
async fn write(&self, _path: &RemotePath, _data: &[u8]) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakePty {
|
||||
next_id: SessionId,
|
||||
kills: Arc<Mutex<Vec<SessionId>>>,
|
||||
}
|
||||
impl FakePty {
|
||||
fn new(next_id: SessionId) -> Self {
|
||||
Self {
|
||||
next_id,
|
||||
kills: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
fn kills(&self) -> Vec<SessionId> {
|
||||
self.kills.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl PtyPort for FakePty {
|
||||
async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
|
||||
Ok(PtyHandle {
|
||||
session_id: self.next_id,
|
||||
})
|
||||
}
|
||||
fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> {
|
||||
Ok(())
|
||||
}
|
||||
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
|
||||
Ok(())
|
||||
}
|
||||
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
||||
Ok(Box::new(std::iter::empty()))
|
||||
}
|
||||
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
||||
self.kills.lock().unwrap().push(handle.session_id);
|
||||
Ok(ExitStatus { code: Some(0) })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
struct SeqIds(Mutex<u128>);
|
||||
impl SeqIds {
|
||||
fn new() -> Self {
|
||||
Self(Mutex::new(1))
|
||||
}
|
||||
}
|
||||
impl IdGenerator for SeqIds {
|
||||
fn new_uuid(&self) -> Uuid {
|
||||
let mut n = self.0.lock().unwrap();
|
||||
let id = Uuid::from_u128(*n);
|
||||
*n += 1;
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn pid(n: u128) -> ProfileId {
|
||||
ProfileId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn sid(n: u128) -> SessionId {
|
||||
SessionId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
||||
"demo",
|
||||
ProjectPath::new("/home/me/proj").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn claude_profile() -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
pid(9),
|
||||
"Claude Code",
|
||||
"claude",
|
||||
Vec::new(),
|
||||
ContextInjection::stdin(),
|
||||
Some("claude --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn scratch_agent(id: AgentId, name: &str, md: &str) -> Agent {
|
||||
Agent::new(id, name, md, pid(9), AgentOrigin::Scratch, false).unwrap()
|
||||
}
|
||||
|
||||
/// Everything wired for a dispatch test.
|
||||
struct Fixture {
|
||||
service: OrchestratorService,
|
||||
contexts: FakeContexts,
|
||||
pty: FakePty,
|
||||
bus: SpyBus,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
}
|
||||
|
||||
fn fixture(contexts: FakeContexts) -> Fixture {
|
||||
let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()]));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
let pty = FakePty::new(sid(777));
|
||||
let bus = SpyBus::default();
|
||||
|
||||
let create = Arc::new(CreateAgentFromScratch::new(
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(bus.clone()),
|
||||
));
|
||||
let launch = Arc::new(LaunchAgent::new(
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||
Arc::new(FakeRuntime),
|
||||
Arc::new(FakeFs),
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(bus.clone()),
|
||||
));
|
||||
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
||||
let close = Arc::new(CloseTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
));
|
||||
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone())));
|
||||
|
||||
let service = OrchestratorService::new(
|
||||
create,
|
||||
launch,
|
||||
list,
|
||||
close,
|
||||
update,
|
||||
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||
Arc::clone(&sessions),
|
||||
);
|
||||
|
||||
Fixture {
|
||||
service,
|
||||
contexts,
|
||||
pty,
|
||||
bus,
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd(json: &str) -> OrchestratorCommand {
|
||||
serde_json::from_str::<OrchestratorRequest>(json)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_unknown_agent_creates_then_launches() {
|
||||
let fx = fixture(FakeContexts::new());
|
||||
|
||||
let out = fx
|
||||
.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
cmd(r#"{ "action":"spawn_agent", "name":"dev-backend", "profile":"claude-code" }"#),
|
||||
)
|
||||
.await
|
||||
.expect("dispatch ok");
|
||||
assert!(out.detail.contains("dev-backend"));
|
||||
|
||||
// The agent was created (manifest grew to one entry) and launched (session
|
||||
// registered as an agent, AgentLaunched published).
|
||||
let manifest = fx.contexts.manifest();
|
||||
assert_eq!(manifest.entries.len(), 1);
|
||||
assert_eq!(manifest.entries[0].name, "dev-backend");
|
||||
|
||||
assert!(fx.sessions.session(&sid(777)).is_some());
|
||||
let launched = fx
|
||||
.bus
|
||||
.events()
|
||||
.into_iter()
|
||||
.any(|e| matches!(e, DomainEvent::AgentLaunched { session_id, .. } if session_id == sid(777)));
|
||||
assert!(launched, "AgentLaunched must be published");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_known_agent_launches_without_recreating() {
|
||||
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
||||
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
|
||||
fx.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
cmd(r#"{ "action":"spawn_agent", "name":"dev-backend", "profile":"claude-code" }"#),
|
||||
)
|
||||
.await
|
||||
.expect("dispatch ok");
|
||||
|
||||
// No second manifest entry — the existing agent was reused, just launched.
|
||||
assert_eq!(fx.contexts.manifest().entries.len(), 1);
|
||||
assert_eq!(fx.contexts.manifest().entries[0].agent_id, agent.id);
|
||||
assert!(fx.sessions.session(&sid(777)).is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_agent_kills_the_right_session() {
|
||||
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
||||
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
|
||||
// Launch it first so a session is registered for the agent.
|
||||
fx.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
cmd(r#"{ "action":"spawn_agent", "name":"dev-backend", "profile":"claude" }"#),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(fx.sessions.session(&sid(777)).is_some());
|
||||
|
||||
// Now stop it.
|
||||
fx.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
cmd(r#"{ "action":"stop_agent", "name":"dev-backend" }"#),
|
||||
)
|
||||
.await
|
||||
.expect("stop ok");
|
||||
|
||||
// The PTY for that session was killed and the session de-registered.
|
||||
assert_eq!(fx.pty.kills(), vec![sid(777)]);
|
||||
assert!(fx.sessions.session(&sid(777)).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_agent_without_live_session_is_not_found() {
|
||||
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
||||
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
|
||||
let err = fx
|
||||
.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
cmd(r#"{ "action":"stop_agent", "name":"dev-backend" }"#),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_context_overwrites_md() {
|
||||
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
||||
let fx = fixture(FakeContexts::with_agent(&agent, "# old"));
|
||||
|
||||
fx.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
cmd(
|
||||
r##"{ "action":"update_agent_context", "name":"dev-backend", "context":"# new body" }"##,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("update ok");
|
||||
|
||||
assert_eq!(
|
||||
fx.contexts.content("agents/dev-backend.md").as_deref(),
|
||||
Some("# new body")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_with_unknown_profile_is_not_found_and_does_not_create() {
|
||||
let fx = fixture(FakeContexts::new());
|
||||
|
||||
let err = fx
|
||||
.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
cmd(r#"{ "action":"spawn_agent", "name":"x", "profile":"does-not-exist" }"#),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
|
||||
// No agent was created when the profile could not be resolved.
|
||||
assert!(fx.contexts.manifest().entries.is_empty());
|
||||
assert!(fx.sessions.session(&sid(777)).is_none());
|
||||
}
|
||||
Reference in New Issue
Block a user