Files
IdeA/crates/domain/src/profile.rs
Blomios 33edbad713 feat(agent): isolate agent cwd in .ideai/run/<id> to kill convention-file collisions
ARCHITECTURE §14.1: an agent's PTY cwd is now its own
`<project_root>/.ideai/run/<agent-id>/` directory, never the project root, so
N agents of the same profile no longer collide on a single conventional file
(CLAUDE.md/AGENTS.md/...).

- profile: cwd_template is now "{agentRunDir}" (built-in catalogue + docs).
- runtime: resolve_cwd substitutes {agentRunDir} (legacy {projectRoot} kept).
- LaunchAgent: computes + creates the run dir via FileSystem::create_dir_all,
  passes it as the cwd base to the pure prepare_invocation. Contract chosen:
  pass run_dir as the `cwd` argument (no PreparedContext change) — keeps
  prepare_invocation pure, I/O stays in the use case.
- convention file is generated by IdeA inside the run dir via a pure
  compose_convention_file(project_root, agent_md): absolute project-root header
  + agent persona (extensible for skills, §14.2).
- .gitignore: ignore .ideai/run/.
- run-dir cleanup left as a TODO (FileSystem port exposes no delete).

Tests: anti-collision (2 agents -> 2 distinct cwd, 2 distinct convention files,
none at root), run-dir creation order, composed convention file; pure unit
tests for agent_run_dir + compose_convention_file; runtime {agentRunDir}
substitution. cargo test --workspace + clippy -D warnings green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:18:14 +02:00

137 lines
4.4 KiB
Rust

//! AI runtime profile and its context-injection strategy.
//!
//! A profile is *declarative configuration* (see CONTEXT.md §9): adding an AI
//! means adding data, not code (Open/Closed). The [`crate::ports::AgentRuntime`]
//! port is parameterised by an [`AgentProfile`].
use serde::{Deserialize, Serialize};
use crate::error::DomainError;
use crate::ids::ProfileId;
/// Strategy for injecting an agent's `.md` context into the launched CLI.
///
/// Invariants:
/// - `ConventionFile.target` is a relative file name without `..` and not absolute,
/// - `Env.var` is a valid environment-variable identifier,
/// - `Flag.flag` is non-empty.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "strategy")]
pub enum ContextInjection {
/// Write/symlink the `.md` to a conventional file (e.g. `CLAUDE.md`).
ConventionFile {
/// Relative target file name.
target: String,
},
/// Pass the context file path through a CLI flag.
Flag {
/// The flag template (e.g. `--context-file {path}` or `-f`).
flag: String,
},
/// Pipe the Markdown content on stdin.
Stdin,
/// Pass the context via an environment variable.
Env {
/// Environment variable name.
var: String,
},
}
impl ContextInjection {
/// Validated `ConventionFile` constructor.
///
/// # Errors
/// Returns [`DomainError::PathNotRelativeSafe`] if `target` is absolute or
/// contains `..`.
pub fn convention_file(target: impl Into<String>) -> Result<Self, DomainError> {
let target = target.into();
crate::validation::relative_safe(&target)?;
Ok(Self::ConventionFile { target })
}
/// Validated `Flag` constructor.
///
/// # Errors
/// Returns [`DomainError::EmptyField`] if `flag` is empty.
pub fn flag(flag: impl Into<String>) -> Result<Self, DomainError> {
let flag = flag.into();
crate::validation::non_empty(&flag, "contextInjection.flag")?;
Ok(Self::Flag { flag })
}
/// `Stdin` constructor (no validation needed).
#[must_use]
pub const fn stdin() -> Self {
Self::Stdin
}
/// Validated `Env` constructor.
///
/// # Errors
/// Returns [`DomainError::InvalidEnvVar`] if `var` is not a valid identifier.
pub fn env(var: impl Into<String>) -> Result<Self, DomainError> {
let var = var.into();
crate::validation::valid_env_var(&var)?;
Ok(Self::Env { var })
}
}
/// Declarative runtime configuration for one AI CLI.
///
/// Invariants:
/// - `name` and `command` non-empty,
/// - `context_injection` is itself valid (guaranteed by its constructors).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentProfile {
/// Stable identifier.
pub id: ProfileId,
/// Display name.
pub name: String,
/// Launch command (e.g. `claude`, `codex`, `gemini`, `aider`).
pub command: String,
/// Static arguments.
pub args: Vec<String>,
/// Context-injection strategy.
pub context_injection: ContextInjection,
/// Optional detection command (e.g. `claude --version`).
pub detect: Option<String>,
/// Working-directory template. Always `"{agentRunDir}"` (ARCHITECTURE §14.1):
/// an agent's PTY cwd is its isolated `.ideai/run/<agent-id>/` directory,
/// **never** the project root, so that N agents of the same profile never
/// collide on a single conventional file (`CLAUDE.md`, …) at the root.
pub cwd_template: String,
}
impl AgentProfile {
/// Builds a validated profile.
///
/// # Errors
/// Returns [`DomainError::EmptyField`] if `name` or `command` is empty.
#[allow(clippy::too_many_arguments)]
pub fn new(
id: ProfileId,
name: impl Into<String>,
command: impl Into<String>,
args: Vec<String>,
context_injection: ContextInjection,
detect: Option<String>,
cwd_template: impl Into<String>,
) -> Result<Self, DomainError> {
let name = name.into();
let command = command.into();
let cwd_template = cwd_template.into();
crate::validation::non_empty(&name, "profile.name")?;
crate::validation::non_empty(&command, "profile.command")?;
Ok(Self {
id,
name,
command,
args,
context_injection,
detect,
cwd_template,
})
}
}