//! 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, } /// 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, ids: Arc, events: Arc, } impl CreateAgentFromScratch { /// Builds the use case from its injected ports. #[must_use] pub fn new( contexts: Arc, ids: Arc, events: Arc, ) -> 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 { 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, } /// Lists a project's agents by reconstructing them from the manifest entries. pub struct ListAgents { contexts: Arc, } impl ListAgents { /// Builds the use case from the [`AgentContextStore`] port. #[must_use] pub fn new(contexts: Arc) -> 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 { 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::, _>>()?; 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, } impl ReadAgentContext { /// Builds the use case. #[must_use] pub fn new(contexts: Arc) -> 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 { 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, } impl UpdateAgentContext { /// Builds the use case. #[must_use] pub fn new(contexts: Arc) -> 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, events: Arc, } impl DeleteAgent { /// Builds the use case. #[must_use] pub fn new(contexts: Arc, events: Arc) -> 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 = 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, } /// 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, profiles: Arc, runtime: Arc, fs: Arc, pty: Arc, skills: Arc, sessions: Arc, events: Arc, } impl LaunchAgent { /// Builds the use case from its injected ports. #[must_use] #[allow(clippy::too_many_arguments)] pub fn new( contexts: Arc, profiles: Arc, runtime: Arc, fs: Arc, pty: Arc, skills: Arc, sessions: Arc, events: Arc, ) -> 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, 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 { 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 // `/.ideai/run//` (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 `/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 // `/.ideai/`) 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 `/.ideai/run//` /// (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::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//`) ; \ 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/.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")); } }