feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
93
crates/application/src/agent/catalogue.rs
Normal file
93
crates/application/src/agent/catalogue.rs
Normal file
@ -0,0 +1,93 @@
|
||||
//! Reference profile **catalogue** — the pre-filled, *editable* profiles offered
|
||||
//! by the first-run wizard (CONTEXT §9, ARCHITECTURE §6 `ConfigureProfiles`).
|
||||
//!
|
||||
//! These are **data, not domain code**: the catalogue lives in the application
|
||||
//! layer (a product decision about *which* AIs to suggest), built from the
|
||||
//! domain's validating constructors. Nothing is imposed — the user picks, edits
|
||||
//! the pre-filled commands, and may add custom profiles. The single
|
||||
//! [`domain::ports::AgentRuntime`] adapter consumes whatever profiles result.
|
||||
//!
|
||||
//! Reference set (CONTEXT §9):
|
||||
//! - **Claude Code** — `claude`, context via `CLAUDE.md` (convention file),
|
||||
//! - **OpenAI Codex CLI** — `codex`, context via `AGENTS.md`,
|
||||
//! - **Gemini CLI** — `gemini`, context via `GEMINI.md`,
|
||||
//! - **Aider** — `aider`, context passed as an argument (`--message-file {path}`).
|
||||
//!
|
||||
//! The ids are **stable, deterministic UUIDs** (derived from a fixed namespace)
|
||||
//! so re-deriving the catalogue yields the same id for "claude" every time,
|
||||
//! making the reference profiles addressable across runs without a registry.
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
|
||||
/// A fixed UUID namespace used to derive stable ids for reference profiles.
|
||||
/// (Random-looking but constant; only its stability matters.)
|
||||
const REFERENCE_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9b1d2a-7c34-4e58-9a1b-2c3d4e5f6a7b");
|
||||
|
||||
/// Derives a stable [`ProfileId`] for a reference profile from its slug.
|
||||
#[must_use]
|
||||
fn reference_id(slug: &str) -> ProfileId {
|
||||
ProfileId::from_uuid(uuid::Uuid::new_v5(&REFERENCE_NAMESPACE, slug.as_bytes()))
|
||||
}
|
||||
|
||||
/// Returns the stable id a reference profile slug maps to (exposed for tests and
|
||||
/// callers that need to address a reference profile).
|
||||
#[must_use]
|
||||
pub fn reference_profile_id(slug: &str) -> ProfileId {
|
||||
reference_id(slug)
|
||||
}
|
||||
|
||||
/// Builds the pre-filled, editable reference profiles (CONTEXT §9).
|
||||
///
|
||||
/// # Panics
|
||||
/// Never in practice: every literal here satisfies the domain invariants, so the
|
||||
/// constructors cannot fail; the `expect`s document that.
|
||||
#[must_use]
|
||||
pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
vec![
|
||||
AgentProfile::new(
|
||||
reference_id("claude"),
|
||||
"Claude Code",
|
||||
"claude",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("CLAUDE.md")
|
||||
.expect("CLAUDE.md is a valid convention target"),
|
||||
Some("claude --version".to_owned()),
|
||||
"{projectRoot}",
|
||||
)
|
||||
.expect("claude reference profile is valid"),
|
||||
AgentProfile::new(
|
||||
reference_id("codex"),
|
||||
"OpenAI Codex CLI",
|
||||
"codex",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("AGENTS.md")
|
||||
.expect("AGENTS.md is a valid convention target"),
|
||||
Some("codex --version".to_owned()),
|
||||
"{projectRoot}",
|
||||
)
|
||||
.expect("codex reference profile is valid"),
|
||||
AgentProfile::new(
|
||||
reference_id("gemini"),
|
||||
"Gemini CLI",
|
||||
"gemini",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("GEMINI.md")
|
||||
.expect("GEMINI.md is a valid convention target"),
|
||||
Some("gemini --version".to_owned()),
|
||||
"{projectRoot}",
|
||||
)
|
||||
.expect("gemini reference profile is valid"),
|
||||
AgentProfile::new(
|
||||
reference_id("aider"),
|
||||
"Aider",
|
||||
"aider",
|
||||
Vec::new(),
|
||||
ContextInjection::flag("--message-file {path}")
|
||||
.expect("aider flag template is non-empty"),
|
||||
Some("aider --version".to_owned()),
|
||||
"{projectRoot}",
|
||||
)
|
||||
.expect("aider reference profile is valid"),
|
||||
]
|
||||
}
|
||||
538
crates/application/src/agent/lifecycle.rs
Normal file
538
crates/application/src/agent/lifecycle.rs
Normal file
@ -0,0 +1,538 @@
|
||||
//! 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, SpawnSpec,
|
||||
};
|
||||
use domain::{
|
||||
Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId,
|
||||
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, 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>,
|
||||
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>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
contexts,
|
||||
profiles,
|
||||
runtime,
|
||||
fs,
|
||||
pty,
|
||||
sessions,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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. Prepare the invocation (pure): command + args + injection plan + cwd.
|
||||
let prepared = PreparedContext {
|
||||
content: content.clone(),
|
||||
relative_path: agent.context_path.clone(),
|
||||
};
|
||||
let mut spec = self
|
||||
.runtime
|
||||
.prepare_invocation(&profile, &prepared, &input.project.root)?;
|
||||
|
||||
// 4. Apply the injection plan side effects *before* spawning.
|
||||
self.apply_injection(&input.project, &agent.context_path, &content, &mut spec)
|
||||
.await?;
|
||||
|
||||
// 5. 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;
|
||||
|
||||
// 6. 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,
|
||||
spec: &mut SpawnSpec,
|
||||
) -> Result<(), AppError> {
|
||||
match spec.context_plan.clone() {
|
||||
Some(ContextInjectionPlan::File { target }) => {
|
||||
// conventionFile spike (ARCHITECTURE §13.6): copy the context to the
|
||||
// conventional file (e.g. CLAUDE.md), overwriting any existing one.
|
||||
// A copy (not a symlink) is the portable choice — Windows symlinks
|
||||
// need privileges and SFTP/WSL symlink semantics differ.
|
||||
let path = RemotePath::new(join(&spec.cwd, &target));
|
||||
self.fs.write(&path, content.as_str().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}")
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
27
crates/application/src/agent/mod.rs
Normal file
27
crates/application/src/agent/mod.rs
Normal file
@ -0,0 +1,27 @@
|
||||
//! Agent-profile use cases & reference catalogue (ARCHITECTURE §6, L5).
|
||||
//!
|
||||
//! This module owns the *profile* side of the AI runtime: detecting which CLIs
|
||||
//! are installed, persisting the chosen/edited/custom profiles, and exposing the
|
||||
//! pre-filled reference catalogue that seeds the first-run wizard. It talks only
|
||||
//! to the [`domain::ports::AgentRuntime`] and [`domain::ports::ProfileStore`]
|
||||
//! ports. Launching an agent (PTY + injection) is L6.
|
||||
|
||||
mod catalogue;
|
||||
mod lifecycle;
|
||||
mod usecases;
|
||||
|
||||
pub(crate) use lifecycle::unique_md_path;
|
||||
|
||||
pub use catalogue::{reference_profile_id, reference_profiles};
|
||||
pub use lifecycle::{
|
||||
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext,
|
||||
UpdateAgentContextInput,
|
||||
};
|
||||
pub use usecases::{
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||
FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles,
|
||||
ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
};
|
||||
323
crates/application/src/agent/usecases.rs
Normal file
323
crates/application/src/agent/usecases.rs
Normal file
@ -0,0 +1,323 @@
|
||||
//! Profile use cases (ARCHITECTURE §6, L5). Each is a single-responsibility
|
||||
//! struct carrying its ports as `Arc<dyn Port>` and exposing one `execute`.
|
||||
//!
|
||||
//! - [`DetectProfiles`] — probe a set of candidate profiles via [`AgentRuntime`]
|
||||
//! and report which CLIs are installed (first-run availability ✓/✗).
|
||||
//! - [`ListProfiles`] / [`SaveProfile`] / [`DeleteProfile`] — CRUD over the
|
||||
//! persisted profiles through the [`ProfileStore`].
|
||||
//! - [`ConfigureProfiles`] — persist a batch of chosen/edited/custom profiles
|
||||
//! (closes the first-run wizard).
|
||||
//! - [`ReferenceProfiles`] — expose the pre-filled, editable catalogue.
|
||||
//! - [`FirstRunState`] — tell the UI whether the first-run wizard should show
|
||||
//! (no `profiles.json` yet) and hand it the reference catalogue.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{AgentRuntime, ProfileStore};
|
||||
use domain::profile::AgentProfile;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::catalogue::reference_profiles;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DetectProfiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`DetectProfiles::execute`]: the candidate profiles to probe.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DetectProfilesInput {
|
||||
/// Profiles whose `detect` command should be run.
|
||||
pub candidates: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Availability of a single candidate after detection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProfileAvailability {
|
||||
/// The probed profile.
|
||||
pub profile: AgentProfile,
|
||||
/// Whether its CLI was detected as installed (exit code 0).
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
/// Output of [`DetectProfiles::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DetectProfilesOutput {
|
||||
/// One entry per candidate (same order), with its availability.
|
||||
pub results: Vec<ProfileAvailability>,
|
||||
}
|
||||
|
||||
/// Probes candidate profiles' detection commands and reports availability.
|
||||
pub struct DetectProfiles {
|
||||
runtime: Arc<dyn AgentRuntime>,
|
||||
}
|
||||
|
||||
impl DetectProfiles {
|
||||
/// Builds the use case from the [`AgentRuntime`] port. The runtime itself
|
||||
/// holds the [`domain::ports::ProcessSpawner`] used for detection.
|
||||
#[must_use]
|
||||
pub fn new(runtime: Arc<dyn AgentRuntime>) -> Self {
|
||||
Self { runtime }
|
||||
}
|
||||
|
||||
/// Runs detection for each candidate. A detection *error* (e.g. the command
|
||||
/// could not even be launched) is reported as `available: false`, not a
|
||||
/// hard failure — the wizard just shows ✗ and the user can still keep the
|
||||
/// profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// Currently never returns `Err` (failures degrade to `available: false`);
|
||||
/// the `Result` keeps the signature uniform with the other use cases.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: DetectProfilesInput,
|
||||
) -> Result<DetectProfilesOutput, AppError> {
|
||||
let results = input
|
||||
.candidates
|
||||
.into_iter()
|
||||
.map(|profile| {
|
||||
let available = self.runtime.detect(&profile).unwrap_or(false);
|
||||
ProfileAvailability { profile, available }
|
||||
})
|
||||
.collect();
|
||||
Ok(DetectProfilesOutput { results })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListProfiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Output of [`ListProfiles::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListProfilesOutput {
|
||||
/// All configured profiles.
|
||||
pub profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Lists the configured profiles from the store.
|
||||
pub struct ListProfiles {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
}
|
||||
|
||||
impl ListProfiles {
|
||||
/// Builds the use case from the [`ProfileStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Lists configured profiles.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self) -> Result<ListProfilesOutput, AppError> {
|
||||
Ok(ListProfilesOutput {
|
||||
profiles: self.store.list().await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SaveProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`SaveProfile::execute`]: the profile to upsert.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveProfileInput {
|
||||
/// The profile to create or replace (by id).
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
/// Output of [`SaveProfile::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveProfileOutput {
|
||||
/// The saved profile (echoed back).
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
/// Persists (creates or replaces) a single profile.
|
||||
pub struct SaveProfile {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
}
|
||||
|
||||
impl SaveProfile {
|
||||
/// Builds the use case from the [`ProfileStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Saves the profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: SaveProfileInput) -> Result<SaveProfileOutput, AppError> {
|
||||
self.store.save(&input.profile).await?;
|
||||
Ok(SaveProfileOutput {
|
||||
profile: input.profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`DeleteProfile::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteProfileInput {
|
||||
/// Id of the profile to delete.
|
||||
pub id: domain::ids::ProfileId,
|
||||
}
|
||||
|
||||
/// Deletes a profile by id.
|
||||
pub struct DeleteProfile {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
}
|
||||
|
||||
impl DeleteProfile {
|
||||
/// Builds the use case from the [`ProfileStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Deletes the profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on
|
||||
/// persistence failure.
|
||||
pub async fn execute(&self, input: DeleteProfileInput) -> Result<(), AppError> {
|
||||
self.store.delete(input.id).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfigureProfiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ConfigureProfiles::execute`]: the chosen/edited/custom profiles.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigureProfilesInput {
|
||||
/// All profiles the user decided to keep (closes the first run).
|
||||
pub profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Output of [`ConfigureProfiles::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigureProfilesOutput {
|
||||
/// The persisted profiles.
|
||||
pub profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Persists the batch of profiles chosen at the end of the first-run wizard.
|
||||
///
|
||||
/// Saving even an empty list creates `profiles.json`, which marks the first run
|
||||
/// as done (so the wizard does not reappear).
|
||||
pub struct ConfigureProfiles {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
}
|
||||
|
||||
impl ConfigureProfiles {
|
||||
/// Builds the use case from the [`ProfileStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Persists each chosen profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ConfigureProfilesInput,
|
||||
) -> Result<ConfigureProfilesOutput, AppError> {
|
||||
for profile in &input.profiles {
|
||||
self.store.save(profile).await?;
|
||||
}
|
||||
// Ensure `profiles.json` exists even when the user kept nothing, so the
|
||||
// first run is recorded as complete.
|
||||
if input.profiles.is_empty() {
|
||||
self.store.mark_configured().await?;
|
||||
}
|
||||
Ok(ConfigureProfilesOutput {
|
||||
profiles: input.profiles,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReferenceProfiles (catalogue accessor)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Output of [`ReferenceProfiles::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReferenceProfilesOutput {
|
||||
/// The pre-filled, editable reference catalogue.
|
||||
pub profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Exposes the pre-filled reference catalogue (Claude/Codex/Gemini/Aider).
|
||||
#[derive(Default)]
|
||||
pub struct ReferenceProfiles;
|
||||
|
||||
impl ReferenceProfiles {
|
||||
/// Builds the (stateless) use case.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Returns the reference catalogue. Infallible.
|
||||
///
|
||||
/// # Errors
|
||||
/// Never; the `Result` keeps the call site uniform.
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn execute(&self) -> Result<ReferenceProfilesOutput, AppError> {
|
||||
Ok(ReferenceProfilesOutput {
|
||||
profiles: reference_profiles(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FirstRunState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Output of [`FirstRunState::execute`]: whether to show the wizard + catalogue.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FirstRunStateOutput {
|
||||
/// `true` when no `profiles.json` exists yet ⇒ show the first-run wizard.
|
||||
pub is_first_run: bool,
|
||||
/// The pre-filled reference catalogue to seed the wizard.
|
||||
pub reference_profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Reports whether the IDE is on its first run (no profiles configured yet) and
|
||||
/// provides the reference catalogue to seed the wizard.
|
||||
pub struct FirstRunState {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
}
|
||||
|
||||
impl FirstRunState {
|
||||
/// Builds the use case from the [`ProfileStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Computes the first-run state.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self) -> Result<FirstRunStateOutput, AppError> {
|
||||
let configured = self.store.is_configured().await?;
|
||||
Ok(FirstRunStateOutput {
|
||||
is_first_run: !configured,
|
||||
reference_profiles: reference_profiles(),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user