Files
IdeA/crates/application/src/agent/lifecycle.rs

704 lines
26 KiB
Rust

//! Agent lifecycle use cases (ARCHITECTURE §6, L6).
//!
//! These own the *project-agent* side (distinct from the profile side in
//! [`super::usecases`]): creating agents and their `.md` contexts under
//! `.ideai/`, listing/reading/updating them, and — the centrepiece —
//! [`LaunchAgent`], which resolves the agent's profile + context, applies the
//! profile's context-injection strategy, opens a PTY cell at the right `cwd` and
//! spawns the CLI.
//!
//! Every use case talks **only to ports** ([`AgentContextStore`], [`ProfileStore`],
//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about
//! a concrete adapter or Tauri.
use std::sync::Arc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext,
ProfileStore, PtyPort, RemotePath, SkillStore, SpawnSpec, StoreError,
};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId,
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
};
use crate::error::AppError;
use crate::terminal::TerminalSessions;
/// Directory (relative to `.ideai/`) under which agent contexts are written.
const AGENTS_SUBDIR: &str = "agents";
// ---------------------------------------------------------------------------
// CreateAgentFromScratch
// ---------------------------------------------------------------------------
/// Input for [`CreateAgentFromScratch::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAgentInput {
/// The project that owns the agent.
pub project: Project,
/// Display name of the agent.
pub name: String,
/// Runtime profile the agent launches with.
pub profile_id: ProfileId,
/// Initial `.md` content (empty when `None`).
pub initial_content: Option<String>,
}
/// Output of [`CreateAgentFromScratch::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAgentOutput {
/// The freshly-created agent.
pub agent: Agent,
}
/// Creates a project agent from scratch: mints an id, derives a unique `.md`
/// path, records the manifest entry, then writes the (possibly empty) context.
pub struct CreateAgentFromScratch {
contexts: Arc<dyn AgentContextStore>,
ids: Arc<dyn domain::ports::IdGenerator>,
events: Arc<dyn EventBus>,
}
impl CreateAgentFromScratch {
/// Builds the use case from its injected ports.
#[must_use]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
ids: Arc<dyn domain::ports::IdGenerator>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
contexts,
ids,
events,
}
}
/// Executes creation.
///
/// Ordering matters: the manifest entry is persisted **before** the context
/// is written, because [`AgentContextStore::write_context`] resolves the
/// on-disk path from the manifest.
///
/// # Errors
/// - [`AppError::Invalid`] if the name is empty or the manifest would become
/// inconsistent,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: CreateAgentInput) -> Result<CreateAgentOutput, AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let id = AgentId::from_uuid(self.ids.new_uuid());
let md_path = unique_md_path(&input.name, &manifest);
let agent = Agent::new(
id,
input.name,
md_path,
input.profile_id,
AgentOrigin::Scratch,
false,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
// Append the entry and re-validate the whole manifest (unique md_paths).
let mut entries = manifest.entries;
entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?;
// Now the path resolves: write the initial context.
let md = MarkdownDoc::new(input.initial_content.unwrap_or_default());
self.contexts
.write_context(&input.project, &agent.id, &md)
.await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project.id,
});
Ok(CreateAgentOutput { agent })
}
}
// ---------------------------------------------------------------------------
// ListAgents
// ---------------------------------------------------------------------------
/// Input for [`ListAgents::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListAgentsInput {
/// The project whose agents to list.
pub project: Project,
}
/// Output of [`ListAgents::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListAgentsOutput {
/// The project's agents (reconstructed from the manifest).
pub agents: Vec<Agent>,
}
/// Lists a project's agents by reconstructing them from the manifest entries.
pub struct ListAgents {
contexts: Arc<dyn AgentContextStore>,
}
impl ListAgents {
/// Builds the use case from the [`AgentContextStore`] port.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Loads the manifest and folds each entry back into an [`Agent`].
///
/// # Errors
/// - [`AppError::Store`] on persistence failure,
/// - [`AppError::Invalid`] if a persisted entry violates an agent invariant.
pub async fn execute(&self, input: ListAgentsInput) -> Result<ListAgentsOutput, AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let agents = manifest
.entries
.iter()
.map(|e| e.to_agent().map_err(|err| AppError::Invalid(err.to_string())))
.collect::<Result<Vec<_>, _>>()?;
Ok(ListAgentsOutput { agents })
}
}
// ---------------------------------------------------------------------------
// ReadAgentContext / UpdateAgentContext
// ---------------------------------------------------------------------------
/// Input for [`ReadAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadAgentContextInput {
/// The owning project.
pub project: Project,
/// The agent whose `.md` to read.
pub agent_id: AgentId,
}
/// Output of [`ReadAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadAgentContextOutput {
/// The agent's Markdown context.
pub content: MarkdownDoc,
}
/// Reads an agent's `.md` context.
pub struct ReadAgentContext {
contexts: Arc<dyn AgentContextStore>,
}
impl ReadAgentContext {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Reads the context.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent (or its `.md`) is unknown,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: ReadAgentContextInput,
) -> Result<ReadAgentContextOutput, AppError> {
let content = self
.contexts
.read_context(&input.project, &input.agent_id)
.await?;
Ok(ReadAgentContextOutput { content })
}
}
/// Input for [`UpdateAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAgentContextInput {
/// The owning project.
pub project: Project,
/// The agent whose `.md` to overwrite.
pub agent_id: AgentId,
/// New Markdown content.
pub content: String,
}
/// Overwrites an agent's `.md` context.
pub struct UpdateAgentContext {
contexts: Arc<dyn AgentContextStore>,
}
impl UpdateAgentContext {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Writes the new context.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: UpdateAgentContextInput) -> Result<(), AppError> {
let md = MarkdownDoc::new(input.content);
self.contexts
.write_context(&input.project, &input.agent_id, &md)
.await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// DeleteAgent
// ---------------------------------------------------------------------------
/// Input for [`DeleteAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteAgentInput {
/// The owning project.
pub project: Project,
/// The agent to remove.
pub agent_id: AgentId,
}
/// Removes an agent from the project manifest.
///
/// The orphaned `.md` file is left on disk: the [`FileSystem`] port exposes no
/// delete, and keeping the file is the safe default (the user may want to recover
/// the context). Re-creating an agent with the same name reuses a fresh path.
pub struct DeleteAgent {
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl DeleteAgent {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
Self { contexts, events }
}
/// Drops the manifest entry for the agent.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is not in the manifest,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let before = manifest.entries.len();
let entries: Vec<ManifestEntry> = manifest
.entries
.into_iter()
.filter(|e| e.agent_id != input.agent_id)
.collect();
if entries.len() == before {
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
}
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project.id,
});
Ok(())
}
}
// ---------------------------------------------------------------------------
// LaunchAgent
// ---------------------------------------------------------------------------
/// Input for [`LaunchAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchAgentInput {
/// The owning project.
pub project: Project,
/// The agent to launch.
pub agent_id: AgentId,
/// Initial terminal height in rows.
pub rows: u16,
/// Initial terminal width in columns.
pub cols: u16,
/// The layout leaf hosting the session (a fresh node when `None`).
pub node_id: Option<NodeId>,
}
/// Output of [`LaunchAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchAgentOutput {
/// The created agent terminal session.
pub session: TerminalSession,
}
/// Launches an agent: resolve profile + context, prepare the invocation, apply
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
///
/// This is the orchestrating use case of L6 and therefore consumes several ports
/// — each only for the slice it needs (Interface Segregation): the context store
/// (agent `.md` + manifest), the profile store (resolve the runtime), the runtime
/// (build the [`SpawnSpec`]), the filesystem (materialise a `conventionFile`
/// context), and the PTY (spawn + optional stdin injection).
pub struct LaunchAgent {
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
runtime: Arc<dyn AgentRuntime>,
fs: Arc<dyn FileSystem>,
pty: Arc<dyn PtyPort>,
skills: Arc<dyn SkillStore>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
}
impl LaunchAgent {
/// Builds the use case from its injected ports.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
runtime: Arc<dyn AgentRuntime>,
fs: Arc<dyn FileSystem>,
pty: Arc<dyn PtyPort>,
skills: Arc<dyn SkillStore>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
contexts,
profiles,
runtime,
fs,
pty,
skills,
sessions,
events,
}
}
/// Resolves the Markdown bodies of an agent's assigned skills, in the
/// **manifest order** (deterministic). A skill that no longer exists in its
/// store (deleted out from under the assignment) is silently skipped — a
/// dangling [`domain::SkillRef`] must not block a launch.
///
/// # Errors
/// [`AppError::Store`] on any store failure other than a missing skill.
async fn resolve_skills(
&self,
agent: &Agent,
root: &ProjectPath,
) -> Result<Vec<Skill>, AppError> {
let mut out = Vec::with_capacity(agent.skills.len());
for skill_ref in &agent.skills {
match self.skills.get(skill_ref.scope, root, skill_ref.skill_id).await {
Ok(skill) => out.push(skill),
Err(StoreError::NotFound) => {}
Err(e) => return Err(e.into()),
}
}
Ok(out)
}
/// Executes the launch.
///
/// Step order is contractually significant (and unit-tested): resolve the
/// agent + context, **`prepare_invocation`**, **apply the injection plan**
/// (write a `conventionFile` / set an env var), then **`pty.spawn`** at the
/// resolved `cwd`, and finally pipe the context on stdin for the `Stdin`
/// strategy.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent or its profile is unknown,
/// - [`AppError::Invalid`] for a zero-sized terminal,
/// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on
/// the respective port failures.
pub async fn execute(&self, input: LaunchAgentInput) -> Result<LaunchAgentOutput, AppError> {
let size =
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
// 1. Resolve the agent from the manifest (name + profile + md_path).
let manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter()
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
// 2. Read its context and resolve its profile.
let content = self
.contexts
.read_context(&input.project, &agent.id)
.await?;
let profile = self
.profiles
.list()
.await?
.into_iter()
.find(|p| p.id == agent.profile_id)
.ok_or_else(|| {
AppError::NotFound(format!("profile {} for agent", agent.profile_id))
})?;
// 3. Compute and create the agent's isolated run directory
// `<root>/.ideai/run/<agent-id>/` (ARCHITECTURE §14.1). The PTY cwd is
// *never* the project root: each agent gets its own directory so that N
// instances of the same profile never collide on a single conventional
// file (CLAUDE.md, …). This is the only I/O in the cwd resolution; the
// runtime's `prepare_invocation` stays pure.
let run_dir = agent_run_dir(&input.project.root, &agent.id)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.fs
.create_dir_all(&RemotePath::new(run_dir.as_str().to_owned()))
.await?;
// 4. Prepare the invocation (pure): command + args + injection plan + cwd.
// The run dir is passed as the cwd base; the profile's `{agentRunDir}`
// placeholder resolves against it.
let prepared = PreparedContext {
content: content.clone(),
relative_path: agent.context_path.clone(),
};
let mut spec = self
.runtime
.prepare_invocation(&profile, &prepared, &run_dir)?;
// 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
// the injection plan side effects *before* spawning.
let skills = self.resolve_skills(&agent, &input.project.root).await?;
self.apply_injection(&input.project, &agent.context_path, &content, &skills, &mut spec)
.await?;
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
let handle = self.pty.spawn(spec.clone(), size).await?;
let session_id = handle.session_id;
// 7. For the Stdin strategy, pipe the context once the PTY is live.
if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) {
self.pty.write(&handle, content.as_str().as_bytes())?;
}
let node_id = input.node_id.unwrap_or_else(NodeId::new_random);
let mut session = TerminalSession::starting(
session_id,
node_id,
spec.cwd.clone(),
SessionKind::Agent {
agent_id: agent.id,
},
size,
);
session.status = SessionStatus::Running;
self.sessions.insert(handle, session.clone());
self.events.publish(DomainEvent::AgentLaunched {
agent_id: agent.id,
session_id,
});
Ok(LaunchAgentOutput { session })
}
/// Applies the context-injection plan that must happen *before* spawn:
/// materialising a `conventionFile` context (write the `.md` to `<cwd>/target`)
/// or attaching the on-disk context path to an environment variable. `Args` is
/// already folded into the spec by the runtime; `Stdin` is handled post-spawn.
async fn apply_injection(
&self,
project: &Project,
context_rel_path: &str,
content: &MarkdownDoc,
skills: &[Skill],
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
match spec.context_plan.clone() {
Some(ContextInjectionPlan::File { target }) => {
// conventionFile (ARCHITECTURE §14.1): IdeA *generates* the
// conventional file (e.g. CLAUDE.md) inside the agent's isolated
// run directory — `spec.cwd` is that run dir, never the project
// root, so there is zero collision between agents. The document is
// composed: an absolute project-root header (so the agent knows
// where to operate, since its cwd is *not* the root), the agent's
// persona `.md`, then the bodies of its assigned skills (§14.2).
let document =
compose_convention_file(project.root.as_str(), content.as_str(), skills);
let path = RemotePath::new(join(&spec.cwd, &target));
self.fs.write(&path, document.as_bytes()).await?;
}
Some(ContextInjectionPlan::Env { var }) => {
// Hand the CLI the absolute path of the agent's `.md` (which lives at
// `<root>/.ideai/<context_rel_path>`) via the environment variable.
let abspath = join(&project.root, &format!(".ideai/{context_rel_path}"));
spec.env.push((var, abspath));
}
// Args were folded into spec.args by prepare_invocation; Stdin is
// applied after the PTY is live.
Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {}
}
Ok(())
}
}
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
/// segment using a POSIX separator.
fn join(base: &ProjectPath, rel: &str) -> String {
let b = base.as_str().trim_end_matches(['/', '\\']);
format!("{b}/{rel}")
}
/// Computes an agent's isolated run directory `<root>/.ideai/run/<agent-id>/`
/// (ARCHITECTURE §14.1). This is the PTY cwd for the agent — never the project
/// root — guaranteeing that two distinct agents on the same project root get two
/// distinct cwd (the anti-collision contract).
///
/// # Errors
/// Propagates [`DomainError`](domain::error::DomainError) if the joined path is
/// not a valid [`ProjectPath`] (should not happen for an absolute project root).
fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result<ProjectPath, domain::error::DomainError> {
ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}")))
}
/// Composes the convention file IdeA writes into an agent's run directory: an
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
/// so it must be told where to work), the agent's persona `.md`, then the bodies
/// of its assigned `skills` under a `# Skills` section (ARCHITECTURE §14.2).
///
/// Skills are emitted in the order given (the caller passes them in manifest
/// order, making the output deterministic); each is introduced by a `##` header
/// carrying its name. When `skills` is empty the section is omitted entirely, so
/// an agent with no skills gets exactly the previous document.
///
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation.
#[must_use]
pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str, skills: &[Skill]) -> String {
let mut out = String::new();
out.push_str("# Project root\n\n");
out.push_str(project_root);
out.push_str("\n\nTous tes travaux portent sur ce project root (chemin absolu ci-dessus). ");
out.push_str(
"Ton répertoire courant est un dossier d'exécution isolé (`.ideai/run/<agent>/`) ; \
opère sur le project root, pas sur ce dossier.\n\n",
);
out.push_str("---\n\n");
out.push_str(agent_md);
if !skills.is_empty() {
out.push_str("\n\n---\n\n# Skills\n");
for skill in skills {
out.push_str("\n## ");
out.push_str(&skill.name);
out.push_str("\n\n");
out.push_str(skill.content_md.as_str());
out.push('\n');
}
}
out
}
/// Derives a unique, filesystem-safe `md_path` (`agents/<slug>.md`) for a new
/// agent, disambiguating against the manifest's existing paths with a numeric
/// suffix when needed. Shared with the template-driven agent creation (L7).
pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String {
let slug = slugify(name);
let base = if slug.is_empty() { "agent".to_owned() } else { slug };
let mut candidate = format!("{AGENTS_SUBDIR}/{base}.md");
let mut n = 2;
while manifest.entries.iter().any(|e| e.md_path == candidate) {
candidate = format!("{AGENTS_SUBDIR}/{base}-{n}.md");
n += 1;
}
candidate
}
/// Lowercases and slugifies a display name into a safe file stem
/// (`[a-z0-9-]`), collapsing runs of separators.
fn slugify(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut prev_dash = false;
for ch in name.trim().chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
out.trim_matches('-').to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_run_dir_is_under_ideai_run_and_unique_per_agent() {
let root = ProjectPath::new("/home/me/proj").unwrap();
let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
let b = AgentId::from_uuid(uuid::Uuid::from_u128(2));
let dir_a = agent_run_dir(&root, &a).unwrap();
let dir_b = agent_run_dir(&root, &b).unwrap();
assert_eq!(dir_a.as_str(), format!("/home/me/proj/.ideai/run/{a}"));
assert_ne!(dir_a, dir_b, "distinct agents → distinct run dirs");
// Never the project root.
assert_ne!(dir_a.as_str(), "/home/me/proj");
}
#[test]
fn compose_convention_file_carries_root_then_persona() {
let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[]);
// Absolute project root present.
assert!(doc.contains("/abs/project/root"));
// Persona present.
assert!(doc.contains("# Persona"));
assert!(doc.contains("Do things."));
// Root header precedes the persona body (ordering of the composition).
let root_at = doc.find("/abs/project/root").unwrap();
let persona_at = doc.find("# Persona").unwrap();
assert!(root_at < persona_at, "root header must precede the persona");
// No skills ⇒ no Skills section.
assert!(!doc.contains("# Skills"));
}
#[test]
fn compose_convention_file_appends_assigned_skills_in_order() {
let s = |n: u128, name: &str, body: &str| {
Skill::new(
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
name,
MarkdownDoc::new(body),
domain::SkillScope::Global,
)
.unwrap()
};
let doc = compose_convention_file(
"/root",
"# Persona",
&[s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY")],
);
// Both skill bodies present, after the persona.
assert!(doc.contains("REFAC_BODY"));
assert!(doc.contains("REVIEW_BODY"));
let persona_at = doc.find("# Persona").unwrap();
let refac_at = doc.find("REFAC_BODY").unwrap();
let review_at = doc.find("REVIEW_BODY").unwrap();
assert!(persona_at < refac_at, "skills come after the persona");
// Deterministic order: first assigned skill precedes the second.
assert!(refac_at < review_at, "skills emitted in the given order");
// Skill names surface as sub-headers.
assert!(doc.contains("## refactor"));
assert!(doc.contains("## review"));
}
}