Branche le SessionLimitService dans l'application Tauri et expose la surface de reprise/annulation au front. - application/agent/lifecycle.rs : LaunchAgentOutput.profile exposé (None sur réattache/idempotent, Some sur lancement effectif). - application/terminal/registry.rs : StructuredSessions::meta_for_session() (lookup agent/node par SessionId pour le tap niveau 1). - app-tauri/state.rs : ResumeContext(s), AppAgentResumer (impl du port AgentResumer au-dessus de LaunchAgent), instanciation + câblage du SessionLimitService (TokioScheduler + drain des réveils) dans AppState::build. - app-tauri/commands.rs : taps niveau 1 (agent_send) et niveau 2 (launch_agent, parser regex confiné), alimentation de resume_contexts, commande cancel_resume. - app-tauri/lib.rs : enregistrement de cancel_resume dans le handler. - app-tauri/Cargo.toml : dépendance async-trait. Tests : session_limit_wiring.rs (2 tests de composition) + meta_for_session dans structured_registry_d1.rs ; fixtures dto_agents/dto_chat ajustées (profile: None). Tout vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3222 lines
142 KiB
Rust
3222 lines
142 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::collections::HashMap;
|
||
use std::sync::Arc;
|
||
|
||
use domain::ports::{
|
||
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
|
||
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext,
|
||
ProfileStore, ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec,
|
||
StoreError,
|
||
};
|
||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
||
use domain::{
|
||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
|
||
ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry,
|
||
MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId,
|
||
ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore,
|
||
PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
||
};
|
||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||
|
||
use crate::error::AppError;
|
||
use crate::layout::{persist_doc, resolve_doc};
|
||
use crate::project::project_context_path;
|
||
use crate::terminal::{StructuredSessions, TerminalSessions};
|
||
|
||
/// Directory (relative to `.ideai/`) under which agent contexts are written.
|
||
const AGENTS_SUBDIR: &str = "agents";
|
||
|
||
/// Token budget of the project-memory recall injected into the convention file at
|
||
/// agent activation (ARCHITECTURE §14.5.4). Bounds the number of index entries
|
||
/// (étage 1) handed to the agent. Internal and intentionally **not yet exposed in
|
||
/// config**: it may later become a per-project setting without changing the
|
||
/// contract.
|
||
pub const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;
|
||
|
||
/// Fournit le [`HandoffStore`] **lié au project root** du lancement en cours (lot P7).
|
||
///
|
||
/// [`LaunchAgent`] est une **instance unique partagée par tous les projets** (le
|
||
/// project root arrive *par lancement* via [`LaunchAgentInput::project`]), alors que
|
||
/// le handoff conversationnel est **par project root**
|
||
/// (`<root>/.ideai/conversations/`, comme la mémoire et le log). Les adapters `Fs*`
|
||
/// fixent leur racine **à la construction** et ne portent pas le root par appel : on
|
||
/// ne peut donc pas figer un `HandoffStore` global. Ce **port** lève la tension —
|
||
/// calqué sur [`crate::RecordTurnProvider`] — en matérialisant un store ciblant le
|
||
/// **bon** dossier à chaque résolution (les adapters `Fs*` ne font que des jointures
|
||
/// de chemin, leur construction est triviale).
|
||
///
|
||
/// `None` ⇒ aucune injection de reprise (best-effort absente) : zéro régression pour
|
||
/// les call sites/tests qui ne le branchent pas. Implémenté dans `app-tauri` (seul
|
||
/// détenteur des adapters `Fs*`).
|
||
pub trait HandoffProvider: Send + Sync {
|
||
/// Construit le [`HandoffStore`] dont la persistance cible `root`. Appelé une fois
|
||
/// par lancement best-effort ; `None` ⇒ on saute silencieusement l'injection.
|
||
fn handoff_store_for(&self, root: &ProjectPath) -> Option<Arc<dyn HandoffStore>>;
|
||
}
|
||
|
||
/// Fournit le [`ProviderSessionStore`] **lié au project root** du lancement en cours
|
||
/// (lot P8b). Jumeau stateless de [`HandoffProvider`] : même tension (instance
|
||
/// [`LaunchAgent`] partagée vs adapter `Fs*` à racine fixée à la construction),
|
||
/// même réponse (matérialiser le store ciblant le **bon** dossier à chaque appel).
|
||
///
|
||
/// Sert à ranger, après un lancement structuré, l'id de session **moteur**
|
||
/// (resumable du provider) sous la clé de paire IdeA dans `providers.json`. `None`
|
||
/// ⇒ aucune écriture (best-effort absente) : zéro régression pour les call
|
||
/// sites/tests qui ne le branchent pas. Implémenté dans `app-tauri`.
|
||
pub trait ProviderSessionProvider: Send + Sync {
|
||
/// Construit le [`ProviderSessionStore`] dont la persistance cible `root`. Appelé
|
||
/// une fois par lancement best-effort ; `None` ⇒ on saute silencieusement
|
||
/// l'écriture du resumable.
|
||
fn provider_session_store_for(
|
||
&self,
|
||
root: &ProjectPath,
|
||
) -> Option<Arc<dyn ProviderSessionStore>>;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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(())
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ChangeAgentProfile
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Input for [`ChangeAgentProfile::execute`].
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct ChangeAgentProfileInput {
|
||
/// The owning project.
|
||
pub project: Project,
|
||
/// The agent whose runtime profile to hot-swap.
|
||
pub agent_id: AgentId,
|
||
/// The new runtime profile.
|
||
pub profile_id: ProfileId,
|
||
/// Terminal height in rows for a possible hot relaunch.
|
||
pub rows: u16,
|
||
/// Terminal width in columns for a possible hot relaunch.
|
||
pub cols: u16,
|
||
}
|
||
|
||
/// Output of [`ChangeAgentProfile::execute`].
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct ChangeAgentProfileOutput {
|
||
/// The mutated agent (now carrying the new profile).
|
||
pub agent: Agent,
|
||
/// The freshly relaunched session, when a live session was hot-swapped.
|
||
pub relaunched: Option<TerminalSession>,
|
||
}
|
||
|
||
/// Hot-swaps an existing agent's runtime profile (ARCHITECTURE §15.1).
|
||
///
|
||
/// **Single Responsibility**: mutate the profile in the manifest, clear the now
|
||
/// foreign conversation id on every persisted layout cell hosting the agent, and
|
||
/// — if the agent is live — kill its PTY and re-sequence the session in the same
|
||
/// cell with the new engine. The relaunch is **composed**, not duplicated: this
|
||
/// use case *calls* [`LaunchAgent::execute`] rather than re-implementing the spawn.
|
||
///
|
||
/// Ports consumed (ISP — only what is needed): [`AgentContextStore`] (manifest),
|
||
/// [`ProfileStore`] (validate the target profile), [`ProjectStore`] +
|
||
/// [`FileSystem`] (clean the conversation id on persisted layouts, exactly like
|
||
/// [`crate::layout::SnapshotRunningAgents`]), [`TerminalSessions`] + [`PtyPort`]
|
||
/// (detect/kill a live session), an [`Arc<LaunchAgent>`] for the hot relaunch, and
|
||
/// [`EventBus`] to publish.
|
||
pub struct ChangeAgentProfile {
|
||
contexts: Arc<dyn AgentContextStore>,
|
||
profiles: Arc<dyn ProfileStore>,
|
||
projects: Arc<dyn ProjectStore>,
|
||
fs: Arc<dyn FileSystem>,
|
||
sessions: Arc<TerminalSessions>,
|
||
pty: Arc<dyn PtyPort>,
|
||
launch: Arc<LaunchAgent>,
|
||
events: Arc<dyn EventBus>,
|
||
/// Registre des sessions **structurées** (§17.5), pour un « kill » polymorphe
|
||
/// (§17.4) : si l'agent a une session structurée vivante, on la `shutdown()` au
|
||
/// lieu de tuer un PTY. Injecté au câblage via [`Self::with_structured`] ; `None`
|
||
/// ⇒ seul le registre PTY est consulté (mode legacy / tests existants).
|
||
structured: Option<Arc<StructuredSessions>>,
|
||
/// Registre des **projecteurs de permissions** (lot LP3-4), pour nettoyer au swap
|
||
/// les fichiers `Replace` orphelins possédés par l'ANCIEN profil que le nouveau ne
|
||
/// réécrira pas. Injecté via [`Self::with_permission_projectors`] (le câblage passe
|
||
/// le **même** `Arc` que celui donné au `LaunchAgent` interne). `None` ⇒ aucun
|
||
/// nettoyage (comportement legacy ; la re-projection du nouveau profil, elle, reste
|
||
/// assurée par le `LaunchAgent` composé).
|
||
projectors: Option<Arc<PermissionProjectorRegistry>>,
|
||
}
|
||
|
||
impl ChangeAgentProfile {
|
||
/// Builds the use case from its injected ports (and the composed launcher).
|
||
#[must_use]
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn new(
|
||
contexts: Arc<dyn AgentContextStore>,
|
||
profiles: Arc<dyn ProfileStore>,
|
||
projects: Arc<dyn ProjectStore>,
|
||
fs: Arc<dyn FileSystem>,
|
||
sessions: Arc<TerminalSessions>,
|
||
pty: Arc<dyn PtyPort>,
|
||
launch: Arc<LaunchAgent>,
|
||
events: Arc<dyn EventBus>,
|
||
) -> Self {
|
||
Self {
|
||
contexts,
|
||
profiles,
|
||
projects,
|
||
fs,
|
||
sessions,
|
||
pty,
|
||
launch,
|
||
events,
|
||
structured: None,
|
||
projectors: None,
|
||
}
|
||
}
|
||
|
||
/// Branche le registre des sessions **structurées** (§17.4) pour un kill
|
||
/// polymorphe au hot-swap. Builder additif : signature de [`Self::new`]
|
||
/// **inchangée** (les tests A existants restent verts), le câblage fait
|
||
/// `ChangeAgentProfile::new(...).with_structured(registry)`.
|
||
#[must_use]
|
||
pub fn with_structured(mut self, structured: Arc<StructuredSessions>) -> Self {
|
||
self.structured = Some(structured);
|
||
self
|
||
}
|
||
|
||
/// Branche le **registre de projecteurs de permissions** (lot LP3-4) pour le
|
||
/// nettoyage des fichiers orphelins au swap cross-profile. Le câblage passe le
|
||
/// **même** `Arc<PermissionProjectorRegistry>` que celui injecté au `LaunchAgent`
|
||
/// interne (source unique de vérité). Builder additif : signature de [`Self::new`]
|
||
/// inchangée ; `None` ⇒ pas de nettoyage (legacy).
|
||
#[must_use]
|
||
pub fn with_permission_projectors(
|
||
mut self,
|
||
projectors: Arc<PermissionProjectorRegistry>,
|
||
) -> Self {
|
||
self.projectors = Some(projectors);
|
||
self
|
||
}
|
||
|
||
/// Executes the hot-swap, following the 7-step algorithm of §15.1.
|
||
///
|
||
/// # Errors
|
||
/// - [`AppError::NotFound`] if the agent or the target profile is unknown,
|
||
/// - [`AppError::Invalid`] on a manifest/layout invariant violation,
|
||
/// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on
|
||
/// the respective port failures (manifest, layouts, PTY kill, relaunch).
|
||
pub async fn execute(
|
||
&self,
|
||
input: ChangeAgentProfileInput,
|
||
) -> Result<ChangeAgentProfileOutput, AppError> {
|
||
// 1. Load the manifest and resolve the agent's entry (NotFound otherwise).
|
||
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)))?;
|
||
|
||
// Capture the PREVIOUS profile id BEFORE mutation (lot LP3-4): it identifies
|
||
// the projector whose now-orphan `Replace` files must be cleaned up at the swap.
|
||
let previous_profile_id = entry.profile_id;
|
||
|
||
// 2. Same profile ⇒ no-op: return the agent unchanged, no kill/relaunch,
|
||
// no event.
|
||
if entry.profile_id == input.profile_id {
|
||
let agent = entry
|
||
.to_agent()
|
||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||
return Ok(ChangeAgentProfileOutput {
|
||
agent,
|
||
relaunched: None,
|
||
});
|
||
}
|
||
|
||
// 3. Validate that the target profile is a known one (ProfileStore.list).
|
||
// Capture both the NEW profile (validation) and the PREVIOUS profile (for
|
||
// the LP3-4 cleanup; absent if it was deleted ⇒ cleanup is skipped).
|
||
let profiles = self.profiles.list().await?;
|
||
let Some(new_profile) = profiles.iter().find(|p| p.id == input.profile_id).cloned() else {
|
||
return Err(AppError::NotFound(format!("profile {}", input.profile_id)));
|
||
};
|
||
let previous_profile = profiles
|
||
.iter()
|
||
.find(|p| p.id == previous_profile_id)
|
||
.cloned();
|
||
|
||
// 4. Mutate the entry (new profile), re-validate (to_agent + manifest)
|
||
// and persist.
|
||
let mut entries = manifest.entries;
|
||
let mut mutated_agent = None;
|
||
for e in &mut entries {
|
||
if e.agent_id == input.agent_id {
|
||
e.profile_id = input.profile_id;
|
||
let agent = e
|
||
.to_agent()
|
||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||
mutated_agent = Some(agent);
|
||
}
|
||
}
|
||
let agent =
|
||
mutated_agent.ok_or_else(|| 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?;
|
||
|
||
// 5. Invalidate the engine link on every persisted layout: for each leaf
|
||
// hosting this agent, drop the (now foreign) engine resumable cache and
|
||
// reset the running flag, while **preserving** the stable IdeA pair id.
|
||
// Mirrors `SnapshotRunningAgents`: resolve_doc → walk agent_leaves →
|
||
// mutate → persist_doc (if changed). Returns the preserved pair id.
|
||
let pair_id = self
|
||
.invalidate_engine_link(&input.project, &input.agent_id)
|
||
.await?;
|
||
|
||
// 5b. (lot LP3-4) Clean up the PREVIOUS profile's now-orphan permission files
|
||
// in the agent's (stable) run dir, BEFORE the relaunch re-projects the new
|
||
// profile — so we never delete a file the new profile is about to write.
|
||
// Only `Replace`-owned paths of the old projector that the new projector
|
||
// does NOT also own are removed (best-effort). `MergeToml` files are never
|
||
// touched. No-op without a projector registry.
|
||
self.cleanup_swapped_out_files(
|
||
&input.project.root,
|
||
&input.agent_id,
|
||
previous_profile.as_ref(),
|
||
&new_profile,
|
||
)
|
||
.await;
|
||
|
||
// 6. A live session? Kill its PTY then relaunch in the same cell with the
|
||
// new profile, carrying the **preserved** pair id (so the handoff is
|
||
// re-injected and resume routes via providers.json[new provider]).
|
||
let relaunched = self.relaunch_if_live(&input, pair_id).await?;
|
||
|
||
// 7. Publish the profile change and return.
|
||
self.events.publish(DomainEvent::AgentProfileChanged {
|
||
agent_id: input.agent_id,
|
||
profile_id: input.profile_id,
|
||
});
|
||
|
||
Ok(ChangeAgentProfileOutput { agent, relaunched })
|
||
}
|
||
|
||
/// Invalidates the **engine link** on every persisted layout leaf hosting
|
||
/// `agent_id` (step 5), while **preserving** the IdeA pair conversation id.
|
||
///
|
||
/// Post-P8a, `LeafCell::conversation_id` is a stable, provider-independent
|
||
/// **pair id** (User↔agent) that retrieves the work log + handoff and must
|
||
/// survive a profile swap. Only the engine-side cache
|
||
/// (`LeafCell::engine_session_id`, a foreign resumable for the new engine) is
|
||
/// cleared; the source of truth for resume routing is `providers.json`. The
|
||
/// running flag is reset too.
|
||
///
|
||
/// Returns the **preserved pair id** of the agent (the first non-`None` leaf;
|
||
/// every leaf of this agent carries the same User↔agent pair id), or `None`
|
||
/// when no hosting leaf was found. Persists only when something actually
|
||
/// changed (a project with no such leaf is a no-op write).
|
||
async fn invalidate_engine_link(
|
||
&self,
|
||
project: &Project,
|
||
agent_id: &AgentId,
|
||
) -> Result<Option<String>, AppError> {
|
||
let project = self.projects.load_project(project.id).await?;
|
||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||
let mut changed = false;
|
||
let mut pair_id: Option<String> = None;
|
||
|
||
for named in &mut doc.layouts {
|
||
for (leaf_id, leaf_agent) in named.tree.agent_leaves() {
|
||
if &leaf_agent != agent_id {
|
||
continue;
|
||
}
|
||
// Capture the preserved pair id (stable across all leaves of this
|
||
// agent) — used to relaunch with handoff re-injection (P7).
|
||
if pair_id.is_none() {
|
||
if let Some(cid) = named
|
||
.tree
|
||
.leaf(leaf_id)
|
||
.and_then(|l| l.conversation_id.clone())
|
||
{
|
||
pair_id = Some(cid);
|
||
}
|
||
}
|
||
// Pure ops — only NodeNotFound is possible, which cannot happen
|
||
// since `leaf_id` came from this very tree.
|
||
//
|
||
// Preserve `conversation_id` (stable pair id); only drop the
|
||
// engine-side resumable cache (foreign to the new engine).
|
||
named.tree = named
|
||
.tree
|
||
.set_cell_engine_session(leaf_id, None)
|
||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||
named.tree = named
|
||
.tree
|
||
.set_agent_running(leaf_id, false)
|
||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
if changed {
|
||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||
}
|
||
Ok(pair_id)
|
||
}
|
||
|
||
/// Removes the **previous** profile's now-orphan permission files at the swap
|
||
/// (lot LP3-4), best-effort. Because the run dir is **stable per agent id**
|
||
/// (`agent_run_dir`), the old CLI's config (e.g. `.claude/settings.local.json`)
|
||
/// survives a swap in the very same directory; left in place it is stale.
|
||
///
|
||
/// Cleanup rule (Architect-validated): delete exactly
|
||
/// `owned_replace_paths(old) − owned_replace_paths(new)` — only the
|
||
/// **`Replace`-owned** files of the old projector that the new projector will not
|
||
/// re-own (and thus re-write). `MergeToml` (co-owned, e.g. `.codex/config.toml`)
|
||
/// files are **never** deleted. Examples: Claude→Codex removes
|
||
/// `.claude/settings.local.json`; Claude→Claude removes nothing (empty diff);
|
||
/// Codex→Claude removes nothing on the Replace side (Codex owns no Replace file).
|
||
///
|
||
/// No-op when: no registry is wired, the previous profile is unknown (deleted),
|
||
/// or it maps to no projector. Every delete is best-effort (`remove_file` is
|
||
/// idempotent), so a missing file never fails the swap. This does **not** touch
|
||
/// the stable pair id or the handoff (P8d) — it only removes engine config files.
|
||
async fn cleanup_swapped_out_files(
|
||
&self,
|
||
project_root: &ProjectPath,
|
||
agent_id: &AgentId,
|
||
previous_profile: Option<&AgentProfile>,
|
||
new_profile: &AgentProfile,
|
||
) {
|
||
let Some(registry) = &self.projectors else {
|
||
return;
|
||
};
|
||
let Some(previous_profile) = previous_profile else {
|
||
return;
|
||
};
|
||
let Some(old_key) = select_projector_key(previous_profile) else {
|
||
return;
|
||
};
|
||
let Some(old_projector) = registry.get(old_key) else {
|
||
return;
|
||
};
|
||
let old_owned = old_projector.owned_replace_paths();
|
||
if old_owned.is_empty() {
|
||
return;
|
||
}
|
||
// Paths the NEW projector will re-own (and re-write) ⇒ never delete these.
|
||
let kept: Vec<String> = select_projector_key(new_profile)
|
||
.and_then(|key| registry.get(key))
|
||
.map(|p| p.owned_replace_paths())
|
||
.unwrap_or_default();
|
||
|
||
let run_dir = match agent_run_dir(project_root, agent_id) {
|
||
Ok(dir) => dir,
|
||
Err(_) => return,
|
||
};
|
||
for rel in old_owned {
|
||
if kept.iter().any(|k| k == &rel) {
|
||
continue;
|
||
}
|
||
let path = RemotePath::new(join(&run_dir, &rel));
|
||
// Best-effort: a missing file / delete failure must never fail the swap.
|
||
let _ = self.fs.remove_file(&path).await;
|
||
}
|
||
}
|
||
|
||
/// Kills the agent's live PTY (if any) and relaunches it in the same cell with
|
||
/// the new profile (step 6), composing [`LaunchAgent::execute`]. Returns the
|
||
/// relaunched session, or `None` when the agent had no live session.
|
||
///
|
||
/// `pair_id` is the **preserved** IdeA pair id (User↔agent) captured at step 5;
|
||
/// it is threaded into the relaunch so the handoff (P7) is re-injected into the
|
||
/// new engine and resume (P8c) is routed via `providers.json[new provider]` —
|
||
/// the old engine's resumable is **never** replayed (providers.json for the new
|
||
/// provider is empty ⇒ `SessionPlan::None`, fidelity carried by the handoff).
|
||
/// When step 5 found no hosting leaf (e.g. a background session without a cell),
|
||
/// the id is **derived** deterministically via `for_pair(User, agent)`.
|
||
async fn relaunch_if_live(
|
||
&self,
|
||
input: &ChangeAgentProfileInput,
|
||
pair_id: Option<String>,
|
||
) -> Result<Option<TerminalSession>, AppError> {
|
||
// Résolution **polymorphe** de la session vivante sur les deux registres
|
||
// (§17.4) : structuré d'abord, puis PTY. Un agent ne vit que dans un seul des
|
||
// deux à la fois (invariant « 1 session/agent »).
|
||
let killed = self.kill_live_session(&input.agent_id).await?;
|
||
let Some(node_id) = killed else {
|
||
// Aucune session vivante (ni structurée, ni PTY) ⇒ rien à relancer.
|
||
return Ok(None);
|
||
};
|
||
|
||
// Pair id préservé (step 5) ; en repli (session de fond sans cellule), on
|
||
// dérive l'id de paire déterministe User↔agent — les cellules sont toujours
|
||
// User↔agent, donc cette dérivation coïncide avec ce qu'eût porté la feuille.
|
||
let conversation_id = Some(pair_id.unwrap_or_else(|| {
|
||
ConversationId::for_pair(
|
||
ConversationParty::User,
|
||
ConversationParty::agent(input.agent_id),
|
||
)
|
||
.to_string()
|
||
}));
|
||
|
||
let output = self
|
||
.launch
|
||
.execute(LaunchAgentInput {
|
||
project: input.project.clone(),
|
||
agent_id: input.agent_id,
|
||
rows: input.rows,
|
||
cols: input.cols,
|
||
node_id,
|
||
// Pair id **préservé** (id de paire IdeA stable) : le handoff (P7) est
|
||
// réinjecté dans le nouveau moteur, et le resume (P8c) est routé via
|
||
// providers.json[nouveau provider] — l'ancien resumable n'est jamais
|
||
// repassé (providers.json du nouveau provider vide ⇒ moteur neuf).
|
||
conversation_id,
|
||
// Internal relaunch (no app-tauri composition root in scope) ⇒ the
|
||
// MCP runtime is not injected here; `apply_mcp_config` falls back to
|
||
// the minimal declaration. A profile-hot-swap that needs the real
|
||
// endpoint is re-driven through the app-tauri launch path.
|
||
mcp_runtime: None,
|
||
})
|
||
.await?;
|
||
Ok(Some(output.session))
|
||
}
|
||
|
||
/// Tue la session vivante de `agent_id` de façon **polymorphe** (§17.4) :
|
||
/// `shutdown()` si elle est structurée, kill PTY sinon. Retire la session de son
|
||
/// registre **avant** l'arrêt, pour que la garde d'unicité du relance (sur les
|
||
/// deux registres) ne voie plus de session vivante.
|
||
///
|
||
/// Retourne `Some(node_id_hôte)` quand une session a été tuée (le node où
|
||
/// relancer), `None` si l'agent n'avait aucune session vivante. La cellule hôte
|
||
/// peut elle-même être absente (session de fond) ⇒ `Some(None)` est replié sur un
|
||
/// node neuf côté relance via `LaunchAgentInput.node_id = None`.
|
||
async fn kill_live_session(
|
||
&self,
|
||
agent_id: &AgentId,
|
||
) -> Result<Option<Option<NodeId>>, AppError> {
|
||
// 1. Session structurée vivante ? ⇒ shutdown polymorphe.
|
||
if let Some(structured) = &self.structured {
|
||
if let Some(session_id) = structured.session_id_for_agent(agent_id) {
|
||
let node_id = structured.node_for_agent(agent_id);
|
||
if let Some(session) = structured.remove(&session_id) {
|
||
session
|
||
.shutdown()
|
||
.await
|
||
.map_err(|e| AppError::Process(e.to_string()))?;
|
||
}
|
||
return Ok(Some(node_id));
|
||
}
|
||
}
|
||
|
||
// 2. Sinon, session PTY vivante ? ⇒ kill PTY (chemin historique).
|
||
let Some(session_id) = self.sessions.session_for_agent(agent_id) else {
|
||
return Ok(None);
|
||
};
|
||
let node_id = self.sessions.node_for_agent(agent_id);
|
||
if let Some(handle) = self.sessions.remove(&session_id) {
|
||
self.pty.kill(&handle).await?;
|
||
}
|
||
Ok(Some(node_id))
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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>,
|
||
/// The persistent CLI conversation id currently recorded on the hosting cell,
|
||
/// if any. `Some` means a previous conversation exists and the launch should
|
||
/// **resume** it; `None` means a fresh cell (the launch may *assign* a new id
|
||
/// when the profile supports it). The caller (which owns the layout) reads this
|
||
/// from the leaf's [`domain::layout::LeafCell::conversation_id`].
|
||
pub conversation_id: Option<String>,
|
||
/// Runtime facts needed to write the **real** IdeA MCP server declaration
|
||
/// (M5d). These are **OS/runtime data** (the IdeA executable path, the
|
||
/// project's loopback endpoint) that live in `app-tauri` — they are *injected
|
||
/// as data* from the composition root, never computed in `application` (which
|
||
/// must not depend on `app-tauri`, cadrage v5 §0.3 / §7).
|
||
///
|
||
/// `None` ⇒ no runtime injected (launches issued from inside `application`:
|
||
/// the orchestrator's `spawn_agent`/`ask_agent`, a profile hot-swap relaunch,
|
||
/// or tests). In that case [`apply_mcp_config`](LaunchAgent::apply_mcp_config)
|
||
/// still honours the profile's `McpConfigStrategy`, but falls back to a
|
||
/// **coherent minimal** declaration (the `idea mcp-server` command without the
|
||
/// endpoint/project/requester args) rather than a project-bound one — see
|
||
/// [`mcp_server_declaration`].
|
||
pub mcp_runtime: Option<McpRuntime>,
|
||
}
|
||
|
||
/// OS/runtime facts injected by the composition root (`app-tauri`) to materialise
|
||
/// the **real** IdeA MCP server declaration in an agent's `.mcp.json` (cadrage v5
|
||
/// §2). Carrying these as **plain data** on [`LaunchAgentInput`] keeps
|
||
/// `application` free of any `app-tauri` / `current_exe` / `mcp_endpoint`
|
||
/// dependency: the endpoint stays computed by the *single source of truth*
|
||
/// (`app-tauri::mcp_endpoint`) and only its **string** crosses the layer boundary.
|
||
///
|
||
/// All four fields are the exact strings the spawned bridge expects on its command
|
||
/// line (`<exe> mcp-server --endpoint <endpoint> --project <project_id> --requester
|
||
/// <requester>`); the `project_id` must already be in the **hyphen-free 32-hex
|
||
/// `simple` form** consumed by the M5c handshake guard (`serve_peer`).
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct McpRuntime {
|
||
/// Absolute path to the IdeA executable (`std::env::current_exe()`), used as the
|
||
/// declaration's `command` — the CLI spawns *this* binary in its `mcp-server`
|
||
/// bridge mode.
|
||
pub exe: String,
|
||
/// The project's loopback endpoint string (`mcp_endpoint(project).as_cli_arg()`),
|
||
/// passed as `--endpoint`. **Same source of truth** as the listener bound by
|
||
/// `ensure_mcp_server` (cadrage v5 §2 coherence invariant).
|
||
pub endpoint: String,
|
||
/// The project id in the **hyphen-free 32-hex `simple` form** consumed by the
|
||
/// M5c handshake guard, passed as `--project`.
|
||
pub project_id: String,
|
||
/// The launching agent's id, passed as `--requester` so the server tags
|
||
/// `OrchestratorRequestProcessed.requester_id` with the real agent (cadrage v5
|
||
/// §1.4) instead of the frozen `"mcp"` placeholder.
|
||
pub requester: String,
|
||
}
|
||
|
||
/// Descripteur d'une session **structurée** (IA, cellule chat) démarrée par
|
||
/// [`LaunchAgent`] (ARCHITECTURE §17.4). Renvoyé en plus du snapshot
|
||
/// [`TerminalSession`] quand le profil porte un `structured_adapter` : il identifie
|
||
/// la session structurée vivante (registre [`StructuredSessions`]) sans faire fuiter
|
||
/// l'`Arc<dyn AgentSession>` à travers la frontière de sortie du use case.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct StructuredSessionDescriptor {
|
||
/// L'id de session IdeA de la session structurée (clé du registre).
|
||
pub session_id: SessionId,
|
||
/// L'agent IA pilotant cette session.
|
||
pub agent_id: AgentId,
|
||
/// La cellule (feuille de layout) qui héberge la vue chat.
|
||
pub node_id: NodeId,
|
||
/// L'id de conversation du moteur, s'il a déjà été attribué.
|
||
pub conversation_id: Option<String>,
|
||
}
|
||
|
||
/// Output of [`LaunchAgent::execute`].
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct LaunchAgentOutput {
|
||
/// The created agent terminal session.
|
||
///
|
||
/// **Toujours présent**, y compris pour un agent IA structuré (cellule chat) :
|
||
/// dans ce cas c'est un snapshot `kind = Agent` portant l'id de la session
|
||
/// structurée (cf. [`Self::structured`]), conformément à §17.4. Garde la sortie
|
||
/// **non cassante** pour A/B et le câblage existant.
|
||
pub session: TerminalSession,
|
||
/// L'**id de paire IdeA** (pivot **logique** de la conversation, ARCHITECTURE
|
||
/// §19.7) assigné par ce lancement, quand la cellule n'en portait pas encore. Le
|
||
/// caller le persiste sur la feuille hôte via `set_cell_conversation` : c'est la
|
||
/// clé qui retrouve **log + handoff** au (re)lancement (P7) et survit au swap de
|
||
/// profil. **Ce n'est plus l'id de session moteur** (qui part désormais dans
|
||
/// [`Self::engine_session_id`]). `None` quand rien de neuf n'a été assigné (reprise
|
||
/// d'un id existant, mode dégradé, ou profil sans session) — rien à persister.
|
||
pub assigned_conversation_id: Option<String>,
|
||
/// L'**id de session moteur** (resumable du provider : id Claude `--resume`, ou
|
||
/// l'UUID minté pour `--session-id`) exposé/attribué par ce lancement, **distinct**
|
||
/// de l'id de paire (ARCHITECTURE §19.7, lot P8a). Le caller le range dans le
|
||
/// **cache** `LeafCell::engine_session_id` (via `set_cell_engine_session`) — jamais
|
||
/// sur `conversation_id`. La **source de vérité** du resumable reste `providers.json`
|
||
/// (lot P8b, hors P8a). `None` pour le chemin PTY/legacy ou quand le moteur n'expose
|
||
/// aucun id : rien à cacher.
|
||
pub engine_session_id: Option<String>,
|
||
/// Descripteur de la session **structurée**, présent **uniquement** quand ce
|
||
/// lancement a routé vers un agent IA structuré (`profile.structured_adapter =
|
||
/// Some(_)`, ARCHITECTURE §17.4). `None` pour le chemin PTY/terminal brut
|
||
/// historique. Champ **additionnel et optionnel** : la sortie reste compatible
|
||
/// avec les use cases A/B et le câblage qui ne lisent que `session` /
|
||
/// `assigned_conversation_id`.
|
||
pub structured: Option<StructuredSessionDescriptor>,
|
||
/// Le **profil résolu** de l'agent lors d'un (re)lancement *effectif* (LS7,
|
||
/// ARCHITECTURE §21.10-4). `LaunchAgent::execute` le résout déjà en interne (zéro
|
||
/// I/O supplémentaire) ; on l'expose pour que le câblage app-tauri décide d'armer —
|
||
/// ou non — le détecteur de limite **niveau 2** (PTY) via la règle de sélection
|
||
/// `infrastructure::ratelimit::applies` (anti-double-détection N1/N2), sans
|
||
/// reconstruire la frontière infra côté domaine. `None` sur une **réattache**
|
||
/// (rebind/idempotent) où aucun profil n'est re-résolu — le câblage n'arme alors
|
||
/// rien (best-effort, le tour est sans nouvelle session).
|
||
pub profile: Option<AgentProfile>,
|
||
}
|
||
|
||
/// Registry mapping each [`ProjectorKey`] to its concrete
|
||
/// [`PermissionProjector`] (lot LP3-3). Injected into [`LaunchAgent`] via the
|
||
/// optional builder [`LaunchAgent::with_permission_projectors`]; the concrete
|
||
/// projectors (Claude / Codex) live in `infrastructure` and are inserted at the
|
||
/// composition root (lot LP3-5). Absent registry ⇒ no projection (legacy
|
||
/// behaviour preserved).
|
||
#[derive(Default, Clone)]
|
||
pub struct PermissionProjectorRegistry {
|
||
by_key: HashMap<ProjectorKey, Arc<dyn PermissionProjector>>,
|
||
}
|
||
|
||
impl PermissionProjectorRegistry {
|
||
/// An empty registry.
|
||
#[must_use]
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
/// Registers a projector under its own [`PermissionProjector::key`] and returns
|
||
/// `self` (builder style), so a registry can be assembled fluently.
|
||
#[must_use]
|
||
pub fn with(mut self, projector: Arc<dyn PermissionProjector>) -> Self {
|
||
self.insert(projector);
|
||
self
|
||
}
|
||
|
||
/// Registers (or replaces) a projector under its own key.
|
||
pub fn insert(&mut self, projector: Arc<dyn PermissionProjector>) {
|
||
self.by_key.insert(projector.key(), projector);
|
||
}
|
||
|
||
/// Returns the projector registered for `key`, if any.
|
||
#[must_use]
|
||
pub fn get(&self, key: ProjectorKey) -> Option<&Arc<dyn PermissionProjector>> {
|
||
self.by_key.get(&key)
|
||
}
|
||
}
|
||
|
||
/// Selects the [`ProjectorKey`] for a launched agent's profile (lot LP3-3).
|
||
///
|
||
/// Primary source: the profile's explicit `projector` field. **Migration
|
||
/// fallback** for legacy profiles (e.g. an old `profiles.json` written before the
|
||
/// field existed): the historical heuristic — a `CLAUDE.md` convention file ⇒
|
||
/// Claude; a `StructuredAdapter::Codex` or a `TomlConfigHome` MCP strategy ⇒
|
||
/// Codex. Anything else ⇒ `None` (no projection).
|
||
fn select_projector_key(profile: &AgentProfile) -> Option<ProjectorKey> {
|
||
if let Some(key) = profile.projector {
|
||
return Some(key);
|
||
}
|
||
// Legacy fallback: Claude is recognised by its `CLAUDE.md` convention file.
|
||
let is_claude = matches!(
|
||
&profile.context_injection,
|
||
ContextInjection::ConventionFile { target }
|
||
if target
|
||
.rsplit(['/', '\\'])
|
||
.next()
|
||
.unwrap_or(target)
|
||
.eq_ignore_ascii_case("CLAUDE.md")
|
||
);
|
||
if is_claude {
|
||
return Some(ProjectorKey::Claude);
|
||
}
|
||
// Legacy fallback: Codex is recognised by its structured adapter or its
|
||
// CODEX_HOME-isolated TOML MCP strategy.
|
||
if matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) {
|
||
return Some(ProjectorKey::Codex);
|
||
}
|
||
if let Some(mcp) = &profile.mcp {
|
||
if matches!(mcp.config, McpConfigStrategy::TomlConfigHome { .. }) {
|
||
return Some(ProjectorKey::Codex);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// 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>,
|
||
ids: Arc<dyn IdGenerator>,
|
||
/// Bounded recall of the project's memory index, injected into the convention
|
||
/// file at activation (ARCHITECTURE §14.5.4). Best-effort by contract: an absent
|
||
/// or empty memory yields an empty list, never blocking a launch.
|
||
recall: Arc<dyn MemoryRecall>,
|
||
/// Optional contextual embedder-suggestion check (LOT C3, §14.5.5), run
|
||
/// best-effort right after the memory recall at activation — the moment an agent
|
||
/// reads the project memory. `None` keeps the launcher independent of it (legacy
|
||
/// wiring / tests). A failure here never affects the launch.
|
||
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
|
||
/// Optional permissions store (LP1). When absent, the launcher keeps its
|
||
/// historical hardcoded CLI permission seeds. When present and a project or
|
||
/// agent policy is configured, the resolved policy is projected to the CLI.
|
||
permissions: Option<Arc<dyn PermissionStore>>,
|
||
/// Fabrique des sessions **structurées** (IA, §17). Injectée au câblage
|
||
/// (composition root) via [`Self::with_structured`]. `None` ⇒ le routage §17.4
|
||
/// est désactivé et **tout** profil suit le chemin PTY historique (mode legacy /
|
||
/// tests existants, qui restent verts sans changement de signature).
|
||
session_factory: Option<Arc<dyn AgentSessionFactory>>,
|
||
/// Registre des sessions structurées vivantes (§17.5), jumeau de
|
||
/// [`TerminalSessions`]. Peuplé quand un agent IA structuré est lancé. `None` en
|
||
/// même temps que [`Self::session_factory`].
|
||
structured: Option<Arc<StructuredSessions>>,
|
||
/// Provider du [`HandoffStore`] **par project root** (lot P7), pour réinjecter
|
||
/// **best-effort** le résumé de reprise (`summary_md` + objectif) de la
|
||
/// conversation de la cellule dans le convention file au (re)lancement. Injecté au
|
||
/// câblage via [`Self::with_handoff_provider`] ; `None` ⇒ aucune injection de
|
||
/// reprise (zéro régression pour les call sites/tests legacy). Un handoff
|
||
/// absent/illisible ⇒ lancement normal, jamais d'échec.
|
||
handoffs: Option<Arc<dyn HandoffProvider>>,
|
||
/// Provider du [`ProviderSessionStore`] **par project root** (lot P8b), pour ranger
|
||
/// **best-effort** l'id de session moteur (resumable du provider) sous la clé de
|
||
/// paire IdeA dans `providers.json` après un lancement structuré. Injecté au câblage
|
||
/// via [`Self::with_provider_session_provider`] ; `None` ⇒ aucune écriture (zéro
|
||
/// régression pour les call sites/tests legacy). Une écriture en échec ⇒ lancement
|
||
/// normal, jamais d'échec.
|
||
provider_sessions: Option<Arc<dyn ProviderSessionProvider>>,
|
||
/// Registre des **projecteurs de permissions** par CLI (lot LP3-3). Injecté au
|
||
/// câblage via [`Self::with_permission_projectors`] ; `None` ⇒ aucune projection
|
||
/// (comportement historique préservé pour les call sites/tests legacy).
|
||
projectors: Option<Arc<PermissionProjectorRegistry>>,
|
||
}
|
||
|
||
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>,
|
||
ids: Arc<dyn IdGenerator>,
|
||
recall: Arc<dyn MemoryRecall>,
|
||
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
|
||
) -> Self {
|
||
Self {
|
||
contexts,
|
||
profiles,
|
||
runtime,
|
||
fs,
|
||
pty,
|
||
skills,
|
||
sessions,
|
||
events,
|
||
ids,
|
||
recall,
|
||
embedder_suggestion,
|
||
permissions: None,
|
||
session_factory: None,
|
||
structured: None,
|
||
handoffs: None,
|
||
provider_sessions: None,
|
||
projectors: None,
|
||
}
|
||
}
|
||
|
||
/// Injects the per-CLI permission **projector registry** (lot LP3-3) used to
|
||
/// translate the resolved [`EffectivePermissions`] into the launched CLI's
|
||
/// concrete permission config. Without this call (legacy call sites / tests),
|
||
/// no projection happens — signature of [`Self::new`] unchanged.
|
||
#[must_use]
|
||
pub fn with_permission_projectors(
|
||
mut self,
|
||
projectors: Arc<PermissionProjectorRegistry>,
|
||
) -> Self {
|
||
self.projectors = Some(projectors);
|
||
self
|
||
}
|
||
|
||
/// Injects the project permission store used to resolve effective agent
|
||
/// permissions at launch time.
|
||
#[must_use]
|
||
pub fn with_permission_store(mut self, permissions: Arc<dyn PermissionStore>) -> Self {
|
||
self.permissions = Some(permissions);
|
||
self
|
||
}
|
||
|
||
/// Branche le provider de **store de sessions provider (lot P8b)** sur ce launcher :
|
||
/// après un lancement structuré exposant un id de session moteur, range
|
||
/// `(pair_conversation_id, provider_key) → engine_session_id` dans `providers.json`
|
||
/// du projet. Sans cet appel (cas legacy / tests existants), aucune écriture —
|
||
/// signature de [`Self::new`] **inchangée**, donc aucun appelant existant ne casse.
|
||
///
|
||
/// Builder additif (consomme `self`, retourne `Self`) : le câblage fait
|
||
/// `LaunchAgent::new(...).with_provider_session_provider(provider)`.
|
||
#[must_use]
|
||
pub fn with_provider_session_provider(
|
||
mut self,
|
||
provider_sessions: Arc<dyn ProviderSessionProvider>,
|
||
) -> Self {
|
||
self.provider_sessions = Some(provider_sessions);
|
||
self
|
||
}
|
||
|
||
/// Branche le provider de **handoff de reprise (lot P7)** sur ce launcher : au
|
||
/// (re)lancement, si la cellule porte une `conversation_id` et qu'un [`Handoff`]
|
||
/// existe pour elle, son `summary_md` (et son objectif) sont injectés comme section
|
||
/// de contexte dans le convention file, à côté de la mémoire projet. Sans cet appel
|
||
/// (cas legacy / tests existants), aucune section de reprise n'est injectée —
|
||
/// signature de [`Self::new`] **inchangée**, donc aucun appelant existant ne casse.
|
||
///
|
||
/// Builder additif (consomme `self`, retourne `Self`) : le câblage fait
|
||
/// `LaunchAgent::new(...).with_handoff_provider(provider)`.
|
||
#[must_use]
|
||
pub fn with_handoff_provider(mut self, handoffs: Arc<dyn HandoffProvider>) -> Self {
|
||
self.handoffs = Some(handoffs);
|
||
self
|
||
}
|
||
|
||
/// Branche le **routage structuré (§17.4)** sur ce launcher : fournit la fabrique
|
||
/// [`AgentSessionFactory`] et le registre [`StructuredSessions`] injectés au
|
||
/// composition root. Sans cet appel (cas legacy / tests existants), `execute`
|
||
/// route **tout** vers le PTY — signature de [`Self::new`] **inchangée**, donc
|
||
/// aucun appelant existant ne casse.
|
||
///
|
||
/// Builder (consomme `self`, retourne `Self`) pour rester additif : le câblage
|
||
/// fait `LaunchAgent::new(...).with_structured(factory, registry)`.
|
||
#[must_use]
|
||
pub fn with_structured(
|
||
mut self,
|
||
session_factory: Arc<dyn AgentSessionFactory>,
|
||
structured: Arc<StructuredSessions>,
|
||
) -> Self {
|
||
self.session_factory = Some(session_factory);
|
||
self.structured = Some(structured);
|
||
self
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Resolves the project's memory recall (index/hooks) to inject into the
|
||
/// convention file at activation (ARCHITECTURE §14.5.4), mirroring
|
||
/// [`Self::resolve_skills`]. The query text is the agent's persona `.md`
|
||
/// (irrelevant to the naïve adapter, but already the right query for the future
|
||
/// semantic recall — zero refactor at étage 2), bounded by
|
||
/// [`AGENT_MEMORY_RECALL_BUDGET`].
|
||
///
|
||
/// **Best-effort, never blocking**: an absent or empty memory yields an empty
|
||
/// list by the [`MemoryRecall`] contract, and any unexpected error degrades to
|
||
/// an empty list rather than failing the launch (exactly like a dangling skill).
|
||
async fn resolve_memory(&self, root: &ProjectPath, persona: &str) -> Vec<MemoryIndexEntry> {
|
||
let query = MemoryQuery {
|
||
text: persona.to_owned(),
|
||
token_budget: AGENT_MEMORY_RECALL_BUDGET,
|
||
};
|
||
self.recall.recall(root, &query).await.unwrap_or_default()
|
||
}
|
||
|
||
/// Résout **best-effort** le handoff de reprise de la cellule (lot P7), calqué sur
|
||
/// [`Self::resolve_memory`]. L'injection n'a lieu que si :
|
||
/// - la cellule porte une `conversation_id` ([`LaunchAgentInput::conversation_id`]),
|
||
/// - le provider de handoff est câblé ([`Self::with_handoff_provider`]),
|
||
/// - cette chaîne se parse en [`ConversationId`] (UUID), et
|
||
/// - un [`Handoff`] existe effectivement pour cette conversation.
|
||
///
|
||
/// **Jamais bloquant** : un parse en échec, l'absence de provider/handoff, ou
|
||
/// toute erreur du store dégradent vers `None` (pas de section de reprise), exactly
|
||
/// comme une mémoire absente — un lancement n'est jamais cassé par la reprise.
|
||
async fn resolve_handoff(
|
||
&self,
|
||
root: &ProjectPath,
|
||
cell_conversation_id: Option<&str>,
|
||
) -> Option<Handoff> {
|
||
let provider = self.handoffs.as_ref()?;
|
||
let raw = cell_conversation_id?;
|
||
// Le `conversation_id` d'une cellule est un identifiant de paire (UUID, §C3).
|
||
// Un id non-UUID (ancien id CLI, donnée corrompue) ⇒ pas d'injection.
|
||
let conversation = ConversationId::from_uuid(uuid::Uuid::parse_str(raw).ok()?);
|
||
let store = provider.handoff_store_for(root)?;
|
||
// Toute erreur de lecture/désérialisation ⇒ pas d'injection (best-effort).
|
||
store.load(conversation).await.ok().flatten()
|
||
}
|
||
|
||
/// Reads the shared project context from `.ideai/CONTEXT.md`.
|
||
///
|
||
/// A missing file is normal for existing projects and simply omits the
|
||
/// project-context section from the generated model context.
|
||
async fn resolve_project_context(&self, project: &Project) -> Result<String, AppError> {
|
||
match self.fs.read(&project_context_path(project)).await {
|
||
Ok(bytes) => String::from_utf8(bytes)
|
||
.map_err(|e| AppError::Store(format!("project context is not UTF-8: {e}"))),
|
||
Err(FsError::NotFound(_)) => Ok(String::new()),
|
||
Err(e) => Err(AppError::FileSystem(e.to_string())),
|
||
}
|
||
}
|
||
|
||
/// 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()))?;
|
||
|
||
// 1b. Enforce the "one live session per agent" invariant (decision: an
|
||
// agent is a singleton that runs in a single cell at a time). This
|
||
// runs AFTER the NotFound resolution above (so an unknown agent still
|
||
// errors NotFound) but BEFORE any I/O (run dir, seed, spawn). If the
|
||
// agent already owns a live session:
|
||
// - with a requested node → rebind the live session to that cell and
|
||
// return it without respawning;
|
||
// - without a requested node → idempotent background/no-op launch:
|
||
// return the existing session without respawning.
|
||
// The resume path (agent dead ⇒ no live session) is unaffected.
|
||
//
|
||
// Invariant « 1 session vivante/agent » **généralisé aux deux registres**
|
||
// (§17.4) : un agent est vivant s'il a une session PTY *ou* structurée.
|
||
// On consulte d'abord le registre PTY (chemin historique), puis le
|
||
// registre structuré (le cas échéant).
|
||
//
|
||
// R0a — discrimination réattache-de-vue vs second lancement (cadrage v5
|
||
// §3.2, Trou A). Un agent est un **singleton** : une seule session vivante.
|
||
// Quand l'agent est déjà vivant, on distingue (cf. [`Self::reattach_decision`]) :
|
||
// - **réattache de vue** (rebind sans respawn) : le `node_id` demandé est
|
||
// le node hôte vivant, OU la cellule porte une `conversation_id`
|
||
// (réattache explicite — la cellule sait que l'agent tournait) ;
|
||
// - **lancement background/idempotent** : ni node, ni conversation ⇒ no-op
|
||
// (rend la session existante, pas de respawn) ;
|
||
// - **second lancement neuf** : on vise un **autre** node, sans signal de
|
||
// réattache ⇒ refus [`AppError::AgentAlreadyRunning`] (node hôte rapporté).
|
||
if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) {
|
||
let host_node = self.sessions.node_for_agent(&input.agent_id);
|
||
match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref()) {
|
||
ReattachDecision::Rebind { node_id } => {
|
||
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id)
|
||
{
|
||
return Ok(LaunchAgentOutput {
|
||
session,
|
||
assigned_conversation_id: None,
|
||
engine_session_id: None,
|
||
structured: None,
|
||
// Réattache (rebind de vue) : aucun profil re-résolu.
|
||
profile: None,
|
||
});
|
||
}
|
||
}
|
||
ReattachDecision::Idempotent => {}
|
||
ReattachDecision::Refuse { node_id } => {
|
||
return Err(AppError::AgentAlreadyRunning {
|
||
agent_id: input.agent_id,
|
||
node_id,
|
||
});
|
||
}
|
||
}
|
||
// Idempotent (or a rebind that found no entry to move) — hand back the
|
||
// already-registered session, no respawn, nothing new to persist.
|
||
if let Some(session) = self.sessions.session(&existing_id) {
|
||
return Ok(LaunchAgentOutput {
|
||
session,
|
||
assigned_conversation_id: None,
|
||
engine_session_id: None,
|
||
structured: None,
|
||
// Idempotent (pas de respawn) : aucun profil re-résolu.
|
||
profile: None,
|
||
});
|
||
}
|
||
}
|
||
// Garde structurée (§17.4) : même sémantique côté registre IA. R0a appliqué de
|
||
// façon identique — rebind de la cellule-vue pour une réattache légitime,
|
||
// idempotence sans node/conversation, refus d'un second lancement neuf ailleurs.
|
||
if let Some(structured) = &self.structured {
|
||
if let Some(existing) = structured.session_for_agent(&input.agent_id) {
|
||
let host_node = structured.node_for_agent(&input.agent_id);
|
||
let node_id = match reattach_decision(
|
||
input.node_id,
|
||
host_node,
|
||
input.conversation_id.as_deref(),
|
||
) {
|
||
ReattachDecision::Rebind { node_id } => {
|
||
let _ = structured.rebind_agent_node(&input.agent_id, node_id);
|
||
node_id
|
||
}
|
||
// Idempotent — garder le node hôte courant, sinon un node neuf.
|
||
ReattachDecision::Idempotent => host_node.unwrap_or_else(NodeId::new_random),
|
||
ReattachDecision::Refuse { node_id } => {
|
||
return Err(AppError::AgentAlreadyRunning {
|
||
agent_id: input.agent_id,
|
||
node_id,
|
||
});
|
||
}
|
||
};
|
||
return Ok(LaunchAgentOutput {
|
||
session: structured_snapshot(&existing, input.agent_id, node_id, size),
|
||
assigned_conversation_id: None,
|
||
// Rebind de vue : aucune (ré)assignation ⇒ rien de neuf à cacher.
|
||
engine_session_id: None,
|
||
structured: Some(StructuredSessionDescriptor {
|
||
session_id: existing.id(),
|
||
agent_id: input.agent_id,
|
||
node_id,
|
||
conversation_id: existing.conversation_id(),
|
||
}),
|
||
// Réattache structurée (rebind de vue) : aucun profil re-résolu.
|
||
profile: None,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 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?;
|
||
|
||
let effective_permissions = self
|
||
.resolve_effective_permissions(&input.project, agent.id)
|
||
.await?;
|
||
|
||
// 3b. (Permission projection moved to step 5c, after the convention file and
|
||
// the MCP config, so both the structured and PTY paths inherit it — see
|
||
// `apply_permission_projection`.)
|
||
|
||
// 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(),
|
||
};
|
||
// 4a. Resolve the session intention (T4). The conversation id is a property
|
||
// of the *cell*, not the PTY: the caller (which owns the layout) passes
|
||
// the cell's current `conversation_id`. Any id this launch *assigns* is
|
||
// returned in the output so the caller persists it on the leaf.
|
||
let (session_plan, assigned_conversation_id) = self
|
||
.resolve_session_plan(&profile, input.conversation_id.clone(), &input.project.root)
|
||
.await;
|
||
let mut spec =
|
||
self.runtime
|
||
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
|
||
|
||
// 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?;
|
||
let project_context = self.resolve_project_context(&input.project).await?;
|
||
let memory = self
|
||
.resolve_memory(&input.project.root, content.as_str())
|
||
.await;
|
||
// Reprise conversationnelle (lot P7) : best-effort, additif. Si la cellule a une
|
||
// conversation et qu'un handoff existe, son résumé est injecté dans le convention
|
||
// file (à côté de la mémoire projet). Indépendant du provider/resumable id.
|
||
let handoff = self
|
||
.resolve_handoff(&input.project.root, input.conversation_id.as_deref())
|
||
.await;
|
||
// Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent
|
||
// has just read the project memory, so this is the moment to check whether a
|
||
// semantic embedder would now help. Fully isolated from the launch outcome —
|
||
// an error or absence of the check never affects activation.
|
||
if let Some(check) = &self.embedder_suggestion {
|
||
let _ = check
|
||
.execute(crate::embedder::CheckEmbedderSuggestionInput {
|
||
project_id: input.project.id,
|
||
project_root: input.project.root.clone(),
|
||
})
|
||
.await;
|
||
}
|
||
self.apply_injection(
|
||
&input.project,
|
||
&agent.context_path,
|
||
&content,
|
||
&project_context,
|
||
&skills,
|
||
&memory,
|
||
handoff.as_ref(),
|
||
profile.mcp.is_some(),
|
||
&mut spec,
|
||
)
|
||
.await?;
|
||
|
||
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
|
||
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
|
||
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
|
||
// IdeA matérialise SA config MCP au format de CETTE CLI, dans le **même**
|
||
// run dir isolé que le convention file et le seed de permissions. `None`
|
||
// ⇒ aucun write/flag/env (chemin actuel inchangé, zéro régression).
|
||
self.apply_mcp_config(
|
||
&profile,
|
||
&run_dir,
|
||
&input.project.root,
|
||
input.mcp_runtime.as_ref(),
|
||
&mut spec,
|
||
)
|
||
.await;
|
||
|
||
// 5c. ── PROJECTION DES PERMISSIONS (lot LP3-3) ──
|
||
// Strictement APRÈS le convention file (5) ET la conf MCP (5a), donc
|
||
// AVANT le split structuré/PTY (5b) et le spawn : les DEUX chemins
|
||
// héritent de la projection (fichiers du plan écrits dans le run dir,
|
||
// `args`/`env` foldés dans `spec`). `None` registre / profil non
|
||
// projetable / `eff == None` ⇒ no-op (prompting natif conservé).
|
||
self.apply_permission_projection(
|
||
&profile,
|
||
&run_dir,
|
||
&input.project.root,
|
||
effective_permissions.as_ref(),
|
||
&mut spec,
|
||
)
|
||
.await?;
|
||
|
||
// 5d. ── PLAN DE SANDBOX OS (lot LP4-3) ──
|
||
// Strictement APRÈS la résolution des permissions (3) et la projection
|
||
// advisory (5c), AVANT le split structuré/PTY : `compile_sandbox_plan`
|
||
// est **pur** (domaine) — le launch path ne fait qu'orchestrer
|
||
// `EffectivePermissions` (déjà lue du store en 3) → `SandboxPlan` et
|
||
// remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan,
|
||
// comportement natif conservé (invariant produit). L'enforcer concret
|
||
// (Landlock/Noop) est injecté côté PTY au composition root.
|
||
spec.sandbox = compile_sandbox_plan(
|
||
effective_permissions.as_ref(),
|
||
&SandboxContext {
|
||
project_root: input.project.root.as_str(),
|
||
run_dir: run_dir.as_str(),
|
||
},
|
||
);
|
||
|
||
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
|
||
// Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le
|
||
// run dir (étape 5) ; la CLI structurée le lira à chaque tour
|
||
// (incarnation « un run par tour »). Si le profil porte un
|
||
// `structured_adapter` ET que la fabrique structurée est câblée, on
|
||
// démarre une `AgentSession` via le port — **pas** de `pty.spawn` — et on
|
||
// l'enregistre dans `StructuredSessions`. Sinon : chemin PTY inchangé.
|
||
if let (Some(factory), Some(structured), true) = (
|
||
self.session_factory.as_ref(),
|
||
self.structured.as_ref(),
|
||
profile.structured_adapter.is_some(),
|
||
) {
|
||
// ── Clé **logique** de la cellule = id de paire IdeA (ARCHITECTURE §19.7,
|
||
// lot P8a) ──
|
||
// - cellule porteuse d'un `conversation_id` (resume, ou lancement délégué
|
||
// qui a déjà injecté l'id de paire A↔B) ⇒ c'est **déjà** l'id de paire,
|
||
// on le conserve tel quel ;
|
||
// - cellule structurée **neuve** (lancement direct utilisateur, aucun
|
||
// requester) ⇒ on **dérive** `pair(User, agent)` via la fonction domaine
|
||
// pure partagée avec `resolve_conversation` (aucun couplage à
|
||
// l'orchestrateur). C'est cet id — et **non** l'id de session moteur —
|
||
// qui retrouve log + handoff au (re)lancement (P7) et survit au swap.
|
||
let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| {
|
||
ConversationId::for_pair(
|
||
ConversationParty::User,
|
||
ConversationParty::agent(agent.id),
|
||
)
|
||
.to_string()
|
||
});
|
||
return self
|
||
.launch_structured(
|
||
factory.as_ref(),
|
||
structured,
|
||
&agent,
|
||
&profile,
|
||
&prepared,
|
||
&run_dir,
|
||
&session_plan,
|
||
pair_conversation_id,
|
||
&input.project.root,
|
||
input.node_id,
|
||
size,
|
||
spec.sandbox.as_ref(),
|
||
)
|
||
.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,
|
||
assigned_conversation_id,
|
||
// Chemin PTY/terminal brut : pas de session moteur structurée à cacher.
|
||
engine_session_id: None,
|
||
structured: None,
|
||
// Lancement effectif : profil résolu exposé pour la sélection du détecteur
|
||
// de limite niveau 2 (§21.10-4) côté câblage.
|
||
profile: Some(profile.clone()),
|
||
})
|
||
}
|
||
|
||
/// Démarre un agent IA **structuré** (§17.4) : crée une [`AgentSession`] via la
|
||
/// fabrique, l'enregistre dans [`StructuredSessions`] avec son `agent_id`/`node_id`
|
||
/// et publie [`DomainEvent::AgentLaunched`]. **Aucun `pty.spawn`** — la cellule est
|
||
/// de type chat.
|
||
///
|
||
/// L'`assigned_conversation_id` calculé en amont (`resolve_session_plan`) est
|
||
/// préservé, mais l'id réellement attribué par le moteur (s'il diffère ou s'il
|
||
/// apparaît seulement après le démarrage) prime quand il est disponible : c'est
|
||
/// `session.conversation_id()` qui fait foi pour la persistance sur la cellule.
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn launch_structured(
|
||
&self,
|
||
factory: &dyn AgentSessionFactory,
|
||
structured: &Arc<StructuredSessions>,
|
||
agent: &Agent,
|
||
profile: &AgentProfile,
|
||
prepared: &PreparedContext,
|
||
run_dir: &ProjectPath,
|
||
session_plan: &SessionPlan,
|
||
pair_conversation_id: String,
|
||
root: &ProjectPath,
|
||
node_id: Option<NodeId>,
|
||
size: PtySize,
|
||
sandbox: Option<&SandboxPlan>,
|
||
) -> Result<LaunchAgentOutput, AppError> {
|
||
// Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`,
|
||
// déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée.
|
||
let session = factory
|
||
.start(profile, prepared, run_dir, session_plan, sandbox)
|
||
.await
|
||
.map_err(|e| AppError::Process(e.to_string()))?;
|
||
|
||
let session_id = session.id();
|
||
let node_id = node_id.unwrap_or_else(NodeId::new_random);
|
||
|
||
// Enregistre la session vivante (invariant « 1 session/agent » : déjà gardé en
|
||
// amont sur les deux registres).
|
||
structured.insert(Arc::clone(&session), agent.id, node_id);
|
||
|
||
// ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ──
|
||
// - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur
|
||
// `LeafCell.conversation_id`. C'est lui qui retrouve log + handoff (P7) et
|
||
// survit au swap. Stable, indépendant du provider.
|
||
// - **id de session moteur** (`session.conversation_id()`) : resumable du
|
||
// provider (Claude/Codex). Rangé **séparément** dans le cache
|
||
// `LeafCell.engine_session_id` (via `set_cell_engine_session`) — **jamais**
|
||
// sur `conversation_id`. `None` si le moteur n'expose encore aucun id.
|
||
let engine_session_id = session.conversation_id();
|
||
|
||
// ── P8b — range le resumable moteur par provider dans `providers.json`
|
||
// (best-effort, jamais bloquant) ──
|
||
// On persiste `(pair_conversation_id, provider_key) → engine_session_id`
|
||
// **uniquement** quand le moteur expose un id ET que le provider est câblé.
|
||
// La clé provider est dérivée de la **famille de moteur** (`structured_adapter`),
|
||
// pas de l'uuid d'instance du profil : un swap vers la même famille réutilise la
|
||
// même entrée. Toute défaillance (provider absent, pair_conversation_id non-UUID,
|
||
// erreur du store) dégrade silencieusement — un lancement n'est **jamais** cassé
|
||
// par cette persistance (lot P8c lira ce store pour `--resume`).
|
||
if let Some(engine_id) = engine_session_id.as_deref() {
|
||
self.persist_provider_session(root, profile, &pair_conversation_id, engine_id)
|
||
.await;
|
||
}
|
||
|
||
let snapshot = structured_snapshot(&session, agent.id, node_id, size);
|
||
|
||
self.events.publish(DomainEvent::AgentLaunched {
|
||
agent_id: agent.id,
|
||
session_id,
|
||
});
|
||
|
||
Ok(LaunchAgentOutput {
|
||
session: snapshot,
|
||
// Cellule ⇐ id de **paire** (pivot logique de reprise §15.2 / §19.7).
|
||
assigned_conversation_id: Some(pair_conversation_id),
|
||
// Cache moteur ⇐ id resumable du provider (distinct de la paire).
|
||
engine_session_id: engine_session_id.clone(),
|
||
structured: Some(StructuredSessionDescriptor {
|
||
session_id,
|
||
agent_id: agent.id,
|
||
node_id,
|
||
conversation_id: engine_session_id,
|
||
}),
|
||
// Lancement structuré effectif : profil résolu exposé (le tap niveau 2 PTY ne
|
||
// l'arme pas — `applies` est faux pour un profil structuré, §21.10-4).
|
||
profile: Some(profile.clone()),
|
||
})
|
||
}
|
||
|
||
/// Range **best-effort** (lot P8b) le resumable moteur `engine_id` sous la clé
|
||
/// `(pair_conversation_id, provider_key)` dans `providers.json` du projet.
|
||
///
|
||
/// `provider_key` est dérivée de la **famille de moteur** du profil
|
||
/// ([`StructuredAdapter::provider_key`]) ; un profil sans `structured_adapter` (ne
|
||
/// devrait pas arriver sur ce chemin structuré) ⇒ skip. `pair_conversation_id` est
|
||
/// une chaîne : un id non-UUID (donnée legacy/corrompue) ⇒ skip. Le provider absent
|
||
/// ([`Self::with_provider_session_provider`] non appelé) ⇒ skip. Toute erreur du
|
||
/// store est tracée et avalée : **jamais** d'échec ni de panique — le lancement a
|
||
/// déjà réussi à ce stade.
|
||
async fn persist_provider_session(
|
||
&self,
|
||
root: &ProjectPath,
|
||
profile: &AgentProfile,
|
||
pair_conversation_id: &str,
|
||
engine_id: &str,
|
||
) {
|
||
let Some(provider) = self.provider_sessions.as_ref() else {
|
||
return;
|
||
};
|
||
let Some(adapter) = profile.structured_adapter else {
|
||
return;
|
||
};
|
||
// Le `conversation_id` d'une cellule est un id de paire (UUID, §C3).
|
||
let Ok(uuid) = uuid::Uuid::parse_str(pair_conversation_id) else {
|
||
return;
|
||
};
|
||
let conversation = ConversationId::from_uuid(uuid);
|
||
let Some(store) = provider.provider_session_store_for(root) else {
|
||
return;
|
||
};
|
||
// Best-effort : toute erreur du store est avalée — le lancement a déjà réussi,
|
||
// la persistance du resumable ne doit jamais le casser (lot P8c lira ce store).
|
||
let _ = store
|
||
.set(conversation, adapter.provider_key(), engine_id)
|
||
.await;
|
||
}
|
||
|
||
/// Resolves the [`SessionPlan`] for a launch from the profile's session
|
||
/// strategy and the cell's current `conversation_id` (T4).
|
||
///
|
||
/// Returns the plan *and* — when this launch mints a fresh id — that id, so the
|
||
/// caller can persist it on the hosting leaf. The id is only generated for an
|
||
/// `Assign` (profile has a `session` block with an `assign_flag`, and the cell
|
||
/// had no id yet); every other branch returns `None` (nothing to persist).
|
||
///
|
||
/// Branches:
|
||
/// - cell already has an id ⇒ [`SessionPlan::Resume`] (reopen) — no new id;
|
||
/// - no id, profile has `session.assign_flag` ⇒ mint a UUID, [`SessionPlan::Assign`];
|
||
/// - no id, profile has `session` but no `assign_flag` (degraded) ⇒
|
||
/// [`SessionPlan::None`] (nothing to resume on a first launch; the adapter
|
||
/// uses the bare resume flag only on later reopens);
|
||
/// - profile without a `session` block ⇒ [`SessionPlan::None`] (legacy).
|
||
async fn resolve_session_plan(
|
||
&self,
|
||
profile: &AgentProfile,
|
||
cell_conversation_id: Option<String>,
|
||
root: &ProjectPath,
|
||
) -> (SessionPlan, Option<String>) {
|
||
// Profil **structuré** (§17, lot P8c) : le `conversation_id` de la cellule est
|
||
// un **id de paire IdeA**, jamais le resumable du moteur. Le passer en `--resume`
|
||
// serait invalide (Claude/Codex attendent *leur* id). On route donc le resume
|
||
// moteur via `providers.json`, keyé par `(id de paire, provider_key)` (écrit en
|
||
// P8b). Rien à pré-attribuer ici : l'id de paire est géré en aval (P8a), le
|
||
// resumable moteur est capté/rapporté par P8b au fil de l'exécution.
|
||
if let Some(adapter) = profile.structured_adapter {
|
||
// Store câblé : on lit le resumable moteur rangé pour cette paire+provider.
|
||
if let Some(provider) = self.provider_sessions.as_ref() {
|
||
let engine = match &cell_conversation_id {
|
||
// L'id de paire (UUID) → resumable moteur via providers.json.
|
||
Some(raw) => match uuid::Uuid::parse_str(raw) {
|
||
Ok(uuid) => match provider.provider_session_store_for(root) {
|
||
Some(store) => store
|
||
.get(ConversationId::from_uuid(uuid), adapter.provider_key())
|
||
.await
|
||
.ok()
|
||
.flatten(),
|
||
None => None,
|
||
},
|
||
Err(_) => None,
|
||
},
|
||
None => None,
|
||
};
|
||
return match engine {
|
||
// Vrai resumable moteur connu ⇒ resume propre du moteur.
|
||
Some(engine_id) => (
|
||
SessionPlan::Resume {
|
||
conversation_id: engine_id,
|
||
},
|
||
None,
|
||
),
|
||
// Aucun resumable (None / parse KO / store absent / pas d'id de
|
||
// cellule) ⇒ premier lancement propre. Le moteur attribuera/rapportera
|
||
// son id (capté par P8b) ; la continuité du travail est assurée par le
|
||
// handoff (P7). On ne passe **jamais** l'id de paire en `--resume`.
|
||
None => (SessionPlan::None, None),
|
||
};
|
||
}
|
||
// Store **non câblé** (tests/legacy) : repli gracieux sur l'ancien
|
||
// comportement — une conversation sur la cellule ⇒ `Resume`, sinon `None`.
|
||
// Garantit zéro régression des tests qui ne câblent pas le store.
|
||
return match cell_conversation_id {
|
||
Some(conversation_id) => (SessionPlan::Resume { conversation_id }, None),
|
||
None => (SessionPlan::None, None),
|
||
};
|
||
}
|
||
|
||
// Profils **non structurés** (PTY/TUI) : la cellule porte toujours l'id moteur.
|
||
// Comportement **inchangé** depuis T4.
|
||
let Some(session) = &profile.session else {
|
||
return (SessionPlan::None, None);
|
||
};
|
||
|
||
// The cell already carries a conversation: resume it (no new id minted).
|
||
if let Some(conversation_id) = cell_conversation_id {
|
||
return (SessionPlan::Resume { conversation_id }, None);
|
||
}
|
||
|
||
// Fresh cell. Only mint+assign an id when the profile can assign one;
|
||
// otherwise (degraded mode) the first launch has nothing to resume.
|
||
if session.assign_flag.is_some() {
|
||
let conversation_id = self.ids.new_uuid().to_string();
|
||
(
|
||
SessionPlan::Assign {
|
||
conversation_id: conversation_id.clone(),
|
||
},
|
||
Some(conversation_id),
|
||
)
|
||
} else {
|
||
(SessionPlan::None, None)
|
||
}
|
||
}
|
||
|
||
/// Projects the resolved [`EffectivePermissions`] into the launched CLI's
|
||
/// concrete permission config (lot LP3-3), replacing the historical per-CLI
|
||
/// seeds. Selects the profile's [`PermissionProjector`] (cf.
|
||
/// [`select_projector_key`]), asks it for a pure [`domain::permission::PermissionProjection`]
|
||
/// (a plan), then **applies** that plan: materialises its files in the run dir
|
||
/// and folds its `args`/`env` into `spec`.
|
||
///
|
||
/// File ownership drives the write regime:
|
||
/// - [`ProjectedFile::Replace`] ⇒ **clobber** (always overwrite). Unlike the old
|
||
/// non-clobbering seed, this lets a re-projection (e.g. after a profile swap)
|
||
/// refresh an IdeA-owned file.
|
||
/// - [`ProjectedFile::MergeToml`] ⇒ merge only the **managed** tables/keys into
|
||
/// any existing file (via the shared TOML helpers), preserving everything else.
|
||
///
|
||
/// No-op when: no registry is wired, the profile has no matching projector, or
|
||
/// the resolved permissions are `None` (the projector returns an empty
|
||
/// projection — native prompting preserved).
|
||
///
|
||
/// # Errors
|
||
/// [`AppError::FileSystem`] if a plan file cannot be written.
|
||
async fn apply_permission_projection(
|
||
&self,
|
||
profile: &AgentProfile,
|
||
run_dir: &ProjectPath,
|
||
project_root: &ProjectPath,
|
||
permissions: Option<&EffectivePermissions>,
|
||
spec: &mut SpawnSpec,
|
||
) -> Result<(), AppError> {
|
||
let Some(registry) = &self.projectors else {
|
||
return Ok(());
|
||
};
|
||
let Some(key) = select_projector_key(profile) else {
|
||
return Ok(());
|
||
};
|
||
let Some(projector) = registry.get(key) else {
|
||
return Ok(());
|
||
};
|
||
|
||
let ctx = ProjectionContext {
|
||
project_root: project_root.as_str(),
|
||
run_dir: run_dir.as_str(),
|
||
};
|
||
let projection = projector.project(permissions, &ctx);
|
||
|
||
for file in &projection.files {
|
||
match file {
|
||
ProjectedFile::Replace { rel_path, contents } => {
|
||
self.ensure_run_dir_parent(run_dir, rel_path).await?;
|
||
let path = RemotePath::new(join(run_dir, rel_path));
|
||
// Clobber: IdeA owns this file; overwriting refreshes it on re-projection.
|
||
self.fs.write(&path, contents.as_bytes()).await?;
|
||
}
|
||
ProjectedFile::MergeToml {
|
||
rel_path,
|
||
managed_tables,
|
||
managed_keys,
|
||
contents,
|
||
} => {
|
||
self.ensure_run_dir_parent(run_dir, rel_path).await?;
|
||
let path = RemotePath::new(join(run_dir, rel_path));
|
||
let existing = match self.fs.read(&path).await {
|
||
Ok(bytes) => String::from_utf8(bytes).unwrap_or_default(),
|
||
Err(_) => String::new(),
|
||
};
|
||
let merged =
|
||
merge_managed_toml(&existing, managed_tables, managed_keys, contents);
|
||
self.fs.write(&path, merged.as_bytes()).await?;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fold the plan's launch args/env into the spec, before the structured/PTY
|
||
// split so both inherit them.
|
||
spec.args.extend(projection.args.iter().cloned());
|
||
spec.env.extend(projection.env.iter().cloned());
|
||
Ok(())
|
||
}
|
||
|
||
/// Ensures the parent directory of `<run_dir>/<rel_path>` exists (e.g. the
|
||
/// `.claude/` or `.codex/` subdir), so a projected file write never fails on a
|
||
/// missing directory. A `rel_path` with no separator needs no extra dir.
|
||
async fn ensure_run_dir_parent(
|
||
&self,
|
||
run_dir: &ProjectPath,
|
||
rel_path: &str,
|
||
) -> Result<(), AppError> {
|
||
if let Some((parent, _)) = rel_path.rsplit_once(['/', '\\']) {
|
||
if !parent.is_empty() {
|
||
self.fs
|
||
.create_dir_all(&RemotePath::new(format!("{}/{parent}", run_dir.as_str())))
|
||
.await?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn resolve_effective_permissions(
|
||
&self,
|
||
project: &Project,
|
||
agent_id: AgentId,
|
||
) -> Result<Option<EffectivePermissions>, AppError> {
|
||
let Some(store) = &self.permissions else {
|
||
return Ok(None);
|
||
};
|
||
let doc = store.load_permissions(project).await?;
|
||
Ok(doc.resolve_for(agent_id))
|
||
}
|
||
|
||
/// 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.
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn apply_injection(
|
||
&self,
|
||
project: &Project,
|
||
context_rel_path: &str,
|
||
content: &MarkdownDoc,
|
||
project_context: &str,
|
||
skills: &[Skill],
|
||
memory: &[MemoryIndexEntry],
|
||
handoff: Option<&Handoff>,
|
||
mcp_enabled: bool,
|
||
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(),
|
||
project_context,
|
||
content.as_str(),
|
||
skills,
|
||
memory,
|
||
handoff,
|
||
mcp_enabled,
|
||
);
|
||
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(())
|
||
}
|
||
|
||
/// Materialises the IdeA MCP server config for **this** CLI when the profile
|
||
/// carries an [`McpCapability`] (cadrage v3, Décision 3). Runs **after** the
|
||
/// convention file (`apply_injection`) and **before** the spawn / `factory.start`,
|
||
/// in the **same** isolated run dir as the convention file and the permission seed.
|
||
///
|
||
/// Dispatch by [`McpConfigStrategy`]:
|
||
/// - `ConfigFile { target }` → write `<run_dir>/<target>` (e.g. `.mcp.json`) with
|
||
/// the IdeA MCP server declaration. **Best-effort** (any write/exists failure is
|
||
/// swallowed so it **never** fails the launch), with two clobber regimes:
|
||
/// * with an injected [`McpRuntime`] (real app-tauri launch) the file is
|
||
/// **regenerated and clobbered on every (re)launch**, exactly like the
|
||
/// convention file. The declaration's `command` carries the **stable** IdeA
|
||
/// exe path (`$APPIMAGE`) and the project's live endpoint — both drift between
|
||
/// runs (the AppImage mount path changes at each remount/reboot), so a stale
|
||
/// `.mcp.json` would point the bridge at a dead binary/socket. `.mcp.json` is
|
||
/// IdeA-managed (not user-edited), so clobbering is safe;
|
||
/// * without a runtime (orchestrator / hot-swap / tests) only a degraded minimal
|
||
/// declaration is available, so the write stays **non-clobbering** (mirrors
|
||
/// [`Self::seed_cli_permissions`]) — never overwriting a real declaration.
|
||
/// - `Flag { flag }` → append the flag + run-dir config path to [`SpawnSpec::args`].
|
||
/// - `Env { var }` → append the variable (run-dir config path) to [`SpawnSpec::env`].
|
||
///
|
||
/// `profile.mcp == None` ⇒ no-op: no write, no flag, no env (current path, zero
|
||
/// regression).
|
||
///
|
||
/// MCP runtime (M5d): when the composition root injects a [`McpRuntime`], the
|
||
/// declaration written for a `ConfigFile` strategy is the **real** one — it points
|
||
/// the spawned `idea mcp-server` bridge at the project's exact loopback endpoint
|
||
/// (same source of truth as `ensure_mcp_server`, cadrage v5 §2). When no runtime is
|
||
/// injected (launches issued from inside `application`), a coherent **minimal**
|
||
/// declaration is written instead (see [`mcp_server_declaration`]). `Flag`/`Env`
|
||
/// are unaffected by the runtime: they keep passing the run-dir config path.
|
||
async fn apply_mcp_config(
|
||
&self,
|
||
profile: &AgentProfile,
|
||
run_dir: &ProjectPath,
|
||
project_root: &ProjectPath,
|
||
runtime: Option<&McpRuntime>,
|
||
spec: &mut SpawnSpec,
|
||
) {
|
||
let Some(mcp) = &profile.mcp else {
|
||
return;
|
||
};
|
||
match &mcp.config {
|
||
domain::profile::McpConfigStrategy::ConfigFile { target } => {
|
||
let path = RemotePath::new(join(run_dir, target));
|
||
let declaration = mcp_server_declaration(mcp.transport, runtime);
|
||
match runtime {
|
||
// Real launch (app-tauri injected the runtime): the declaration
|
||
// carries the **stable** IdeA executable path (`$APPIMAGE`, resolved
|
||
// by `idea_exe_path`) and the project's live loopback endpoint. Both
|
||
// can drift between runs — the AppImage internal mount path changes
|
||
// at every remount/reboot — so the file MUST be regenerated and
|
||
// **clobbered** on every (re)launch, exactly like the convention file
|
||
// (`apply_injection`). `.mcp.json` is IdeA-managed, not user-edited,
|
||
// so there is nothing to preserve. Best-effort: a write failure must
|
||
// never fail the launch.
|
||
Some(_) => {
|
||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
||
}
|
||
// Internal launch (orchestrator / hot-swap / tests): we only have a
|
||
// degraded **minimal** declaration (no endpoint/project/requester).
|
||
// Stay non-clobbering — mirrors `seed_cli_permissions` — so we never
|
||
// overwrite a real declaration previously written by an app-tauri
|
||
// launch with the minimal one.
|
||
None => match self.fs.exists(&path).await {
|
||
Ok(true) => {}
|
||
Ok(false) => {
|
||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
||
}
|
||
Err(_) => {}
|
||
},
|
||
}
|
||
}
|
||
domain::profile::McpConfigStrategy::TomlConfigHome { target, home_env } => {
|
||
// Pendant Codex de `ConfigFile` : on écrit un `config.toml` (table
|
||
// `[mcp_servers.idea]`, via l'encodeur TOML partagé D2) au chemin
|
||
// `<run_dir>/<target>`, et on pousse `home_env` (ex. `CODEX_HOME`) vers
|
||
// le **dossier parent** de ce fichier. Codex lit alors ses serveurs MCP
|
||
// dans ce `config.toml` ISOLÉ au run dir, jamais le `~/.codex` global.
|
||
let path = RemotePath::new(join(run_dir, target));
|
||
let declaration = mcp_server_wiring(mcp.transport, runtime).to_config_toml();
|
||
// Même régime clobber/non-clobber que `.mcp.json` : avec un runtime réel
|
||
// (lancement app-tauri) on régénère/clobber à chaque (re)lancement (l'exe
|
||
// `$APPIMAGE` et l'endpoint dérivent entre runs) ; sans runtime
|
||
// (orchestrateur / hot-swap / tests) on reste non-clobbering pour ne pas
|
||
// écraser une déclaration réelle par la minimale.
|
||
match runtime {
|
||
Some(_) => {
|
||
let existing = match self.fs.read(&path).await {
|
||
Ok(bytes) => String::from_utf8(bytes).ok(),
|
||
Err(_) => None,
|
||
};
|
||
let rendered = codex_config_toml(
|
||
existing.as_deref(),
|
||
&declaration,
|
||
run_dir.as_str(),
|
||
project_root.as_str(),
|
||
);
|
||
let _ = self.fs.write(&path, rendered.as_bytes()).await;
|
||
}
|
||
None => match self.fs.exists(&path).await {
|
||
Ok(true) => {}
|
||
Ok(false) => {
|
||
let rendered = codex_config_toml(
|
||
None,
|
||
&declaration,
|
||
run_dir.as_str(),
|
||
project_root.as_str(),
|
||
);
|
||
let _ = self.fs.write(&path, rendered.as_bytes()).await;
|
||
}
|
||
Err(_) => {}
|
||
},
|
||
}
|
||
// Permission projection (sandbox_mode/approval_policy + --sandbox/
|
||
// --ask-for-approval) is NO LONGER done here — it is decoupled into
|
||
// `apply_permission_projection` (lot LP3-3), so a Codex profile gets its
|
||
// sandbox even without an MCP capability. `apply_mcp_config` is now
|
||
// MCP-only (mcp_servers.idea table + projects trust).
|
||
// `home_env` pointe sur le DOSSIER PARENT de `target` (ex.
|
||
// `{runDir}/.codex`), pas sur le fichier — Codex y cherche `config.toml`.
|
||
let home_dir = parent_dir(run_dir, target);
|
||
spec.env.push((home_env.clone(), home_dir));
|
||
}
|
||
domain::profile::McpConfigStrategy::Flag { flag } => {
|
||
// Pass the server via a launch flag (e.g. `--mcp-config {path}`). The
|
||
// config path is the run dir itself (the CLI's cwd), where the server
|
||
// declaration / connection is anchored.
|
||
spec.args.push(flag.clone());
|
||
spec.args.push(run_dir.as_str().to_owned());
|
||
}
|
||
domain::profile::McpConfigStrategy::Env { var } => {
|
||
spec.env.push((var.clone(), run_dir.as_str().to_owned()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Outcome of the R0a discrimination between a legitimate **view reattach** and a
|
||
/// **fresh second launch** of an already-live agent (cadrage v5 §3.2, Trou A).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub(crate) enum ReattachDecision {
|
||
/// Rebind the live session's view to `node_id` without respawning (the request
|
||
/// targets the live host node, or carries an explicit reattach signal).
|
||
Rebind { node_id: NodeId },
|
||
/// Background / idempotent no-op: hand back the existing session unchanged
|
||
/// (neither a node nor a conversation id was supplied).
|
||
Idempotent,
|
||
/// Refuse the launch — a genuine second launch of a singleton agent already
|
||
/// live on `node_id` (the host node, reported in `AgentAlreadyRunning`).
|
||
Refuse { node_id: NodeId },
|
||
}
|
||
|
||
impl ReattachDecision {
|
||
/// Decides reattach vs refuse for an already-live agent. See [`reattach_decision`].
|
||
#[must_use]
|
||
pub(crate) fn resolve(
|
||
requested_node: Option<NodeId>,
|
||
host_node: Option<NodeId>,
|
||
conversation_id: Option<&str>,
|
||
) -> Self {
|
||
reattach_decision(requested_node, host_node, conversation_id)
|
||
}
|
||
}
|
||
|
||
/// Decides whether a launch hitting an already-live agent is a legitimate view
|
||
/// **reattach** (rebind), a **background/idempotent** no-op, or a **refused** second
|
||
/// launch (cadrage v5 §3.2, Trou A). Pure (no I/O), unit-testable.
|
||
///
|
||
/// Signals (both already present in the launch flow):
|
||
/// - `requested_node` — the hosting leaf the caller targets (`None` ⇒ no cell, e.g.
|
||
/// a background launch);
|
||
/// - `host_node` — the node currently hosting the agent's live session, if known;
|
||
/// - `conversation_id` — the conversation id recorded on the hosting cell. Its
|
||
/// presence means the **cell knows the agent was running** ⇒ it is a reattach of a
|
||
/// view onto an in-progress session, not a brand-new launch.
|
||
///
|
||
/// Rules:
|
||
/// - requested node **is** the host node ⇒ `Rebind` (re-open of the same cell);
|
||
/// - a `conversation_id` is present ⇒ `Rebind` (explicit reattach, even on another
|
||
/// node — the cell is a rebindable view, §17.6);
|
||
/// - no node **and** no conversation ⇒ `Idempotent` (background/no-op relaunch);
|
||
/// - a different node, no reattach signal ⇒ `Refuse` (genuine second launch).
|
||
fn reattach_decision(
|
||
requested_node: Option<NodeId>,
|
||
host_node: Option<NodeId>,
|
||
conversation_id: Option<&str>,
|
||
) -> ReattachDecision {
|
||
// Explicit reattach: the cell carries a conversation id, so it is re-binding a
|
||
// view onto an already-running session. Rebind to the requested node, or keep the
|
||
// current host node when none was supplied.
|
||
if conversation_id.is_some() {
|
||
if let Some(node_id) = requested_node.or(host_node) {
|
||
return ReattachDecision::Rebind { node_id };
|
||
}
|
||
return ReattachDecision::Idempotent;
|
||
}
|
||
|
||
match requested_node {
|
||
// Same cell re-opened ⇒ idempotent rebind (no respawn).
|
||
Some(node) if host_node == Some(node) => ReattachDecision::Rebind { node_id: node },
|
||
// A different cell with no reattach signal ⇒ genuine second launch: refuse,
|
||
// reporting the live host node (falling back to the requested node only if the
|
||
// host is somehow unknown, so the error always carries a meaningful cell).
|
||
Some(node) => ReattachDecision::Refuse {
|
||
node_id: host_node.unwrap_or(node),
|
||
},
|
||
// No node and no reattach signal ⇒ background/idempotent no-op.
|
||
None => ReattachDecision::Idempotent,
|
||
}
|
||
}
|
||
|
||
/// Builds the IdeA MCP server declaration written into a CLI's `ConfigFile` MCP
|
||
/// config (cadrage v3 D3 ; cadrage v5 §2). Kept pure (no I/O) so it is unit-testable.
|
||
///
|
||
/// Two shapes, by whether the composition root injected a [`McpRuntime`]:
|
||
///
|
||
/// - **`Some(runtime)` — the real declaration (M5d, end of the placeholder)**:
|
||
/// `command = runtime.exe` (the IdeA executable), and `args` carry the
|
||
/// `mcp-server` subcommand plus the project's exact loopback `--endpoint`, its
|
||
/// `--project` (hyphen-free 32-hex form, consumed by the M5c guard) and the
|
||
/// launching agent as `--requester`. The endpoint string comes verbatim from the
|
||
/// **single source of truth** (`app-tauri::mcp_endpoint`), so the bridge connects
|
||
/// to exactly what `ensure_mcp_server` listens on.
|
||
///
|
||
/// - **`None` — coherent minimal declaration**: launches issued from inside
|
||
/// `application` (orchestrator/hot-swap/tests) have no OS/runtime facts to inject;
|
||
/// rather than fabricate a project-bound endpoint, we write the `idea mcp-server`
|
||
/// command without the endpoint/project/requester args. It stays a valid,
|
||
/// self-consistent `mcpServers/idea` entry (the bridge would resolve the endpoint
|
||
/// from its own project context), surfacing `transport` for the future socket path.
|
||
#[must_use]
|
||
fn mcp_server_declaration(
|
||
transport: domain::profile::McpTransport,
|
||
runtime: Option<&McpRuntime>,
|
||
) -> String {
|
||
mcp_server_wiring(transport, runtime).to_mcp_json()
|
||
}
|
||
|
||
/// Builds the IdeA MCP server **wiring** (`command` + `args` + transport) shared by
|
||
/// every materialisation format (cadrage Codex D2). It is the **single source of
|
||
/// truth** for *what* the bridge is launched as; the concrete bytes (`.mcp.json`
|
||
/// JSON for Claude, `config.toml` TOML for Codex) are produced by the domain
|
||
/// encoders [`domain::McpServerWiring::to_mcp_json`] /
|
||
/// [`domain::McpServerWiring::to_config_toml`], so the two sites can never drift.
|
||
///
|
||
/// `Some(runtime)` ⇒ the **real** wiring (this IdeA exe + the project's loopback
|
||
/// endpoint/project/requester); `None` ⇒ the coherent **minimal** `idea mcp-server`
|
||
/// fallback (orchestrator / hot-swap / tests).
|
||
#[must_use]
|
||
fn mcp_server_wiring(
|
||
transport: domain::profile::McpTransport,
|
||
runtime: Option<&McpRuntime>,
|
||
) -> domain::McpServerWiring {
|
||
let (command, args) = match runtime {
|
||
Some(rt) => (
|
||
rt.exe.clone(),
|
||
vec![
|
||
"mcp-server".to_owned(),
|
||
"--endpoint".to_owned(),
|
||
rt.endpoint.clone(),
|
||
"--project".to_owned(),
|
||
rt.project_id.clone(),
|
||
"--requester".to_owned(),
|
||
rt.requester.clone(),
|
||
],
|
||
),
|
||
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
|
||
};
|
||
domain::McpServerWiring::new(command, args, transport)
|
||
}
|
||
|
||
/// 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}")
|
||
}
|
||
|
||
/// Resolves the **parent directory** (absolute) of `<base>/<rel>` — used to point a
|
||
/// CLI's `home_env` (e.g. `CODEX_HOME`) at the directory *containing* the materialised
|
||
/// config file (`config.toml`), not the file itself. When `rel` has no separator
|
||
/// (file directly in the run dir), the parent is the run dir.
|
||
fn parent_dir(base: &ProjectPath, rel: &str) -> String {
|
||
let full = join(base, rel);
|
||
match full.rsplit_once(['/', '\\']) {
|
||
Some((parent, _)) if !parent.is_empty() => parent.to_owned(),
|
||
_ => base.as_str().trim_end_matches(['/', '\\']).to_owned(),
|
||
}
|
||
}
|
||
|
||
/// 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).
|
||
pub(crate) fn agent_run_dir(
|
||
root: &ProjectPath,
|
||
agent_id: &AgentId,
|
||
) -> Result<ProjectPath, domain::error::DomainError> {
|
||
ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}")))
|
||
}
|
||
|
||
/// Construit le snapshot [`TerminalSession`] qui représente une session
|
||
/// **structurée** (cellule chat) dans la sortie de [`LaunchAgent`] (§17.4).
|
||
///
|
||
/// Le modèle de sortie reste le même que pour le PTY (non cassant pour A/B) : un
|
||
/// snapshot `kind = Agent` portant l'id de la session structurée. Le `cwd` n'a pas
|
||
/// de sens process ici (la cellule chat n'a pas de PTY) ; on réutilise le run dir
|
||
/// au câblage si besoin, mais le snapshot ne porte qu'un placeholder cohérent.
|
||
fn structured_snapshot(
|
||
session: &Arc<dyn domain::ports::AgentSession>,
|
||
agent_id: AgentId,
|
||
node_id: NodeId,
|
||
size: PtySize,
|
||
) -> TerminalSession {
|
||
// La cellule chat n'a pas de cwd PTY ; un chemin racine neutre suffit au snapshot
|
||
// (le run dir réel reste connu de l'adapter). `ProjectPath::new("/")` ne peut pas
|
||
// échouer (chemin absolu trivial).
|
||
let cwd = ProjectPath::new("/").expect("/ est un ProjectPath absolu valide");
|
||
let mut snapshot = TerminalSession::starting(
|
||
session.id(),
|
||
node_id,
|
||
cwd,
|
||
SessionKind::Agent { agent_id },
|
||
size,
|
||
);
|
||
snapshot.status = SessionStatus::Running;
|
||
snapshot
|
||
}
|
||
|
||
/// Minimal JSON string escaper used by [`toml_string`] (and historically by the
|
||
/// Claude seed, now extracted to the infra projector). Handles the characters that
|
||
/// actually occur in paths: backslash, quote, control chars.
|
||
fn json_escape(s: &str) -> String {
|
||
let mut out = String::with_capacity(s.len());
|
||
for c in s.chars() {
|
||
match c {
|
||
'"' => out.push_str("\\\""),
|
||
'\\' => out.push_str("\\\\"),
|
||
'\n' => out.push_str("\\n"),
|
||
'\r' => out.push_str("\\r"),
|
||
'\t' => out.push_str("\\t"),
|
||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||
c => out.push(c),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn toml_string(s: &str) -> String {
|
||
format!("\"{}\"", json_escape(s))
|
||
}
|
||
|
||
/// Renders Codex's `config.toml` **MCP part only** (lot LP3-3 decoupling): merges
|
||
/// the `[mcp_servers.idea]` table and ensures the run-dir + project-root trust
|
||
/// entries. The permission part (`sandbox_mode` / `approval_policy` + the
|
||
/// `--sandbox` / `--ask-for-approval` args) now lives in the Codex permission
|
||
/// projector (`apply_permission_projection`), so this function is MCP-only and a
|
||
/// Codex profile receives its sandbox even without an MCP capability.
|
||
fn codex_config_toml(
|
||
existing: Option<&str>,
|
||
mcp_declaration: &str,
|
||
run_dir: &str,
|
||
project_root: &str,
|
||
) -> String {
|
||
let mut text = existing.unwrap_or_default().to_owned();
|
||
text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end());
|
||
text = ensure_codex_trust(&text, run_dir);
|
||
text = ensure_codex_trust(&text, project_root);
|
||
if !text.ends_with('\n') {
|
||
text.push('\n');
|
||
}
|
||
text
|
||
}
|
||
|
||
/// Merges a projector's **managed** TOML fragment into an existing `config.toml`
|
||
/// (lot LP3-3, [`ProjectedFile::MergeToml`]). Only the `managed_tables` and
|
||
/// `managed_keys` are touched; every other line of `existing` is preserved.
|
||
///
|
||
/// - each managed table is replaced wholesale by its block from `fragment`
|
||
/// ([`replace_toml_table`]);
|
||
/// - each managed top-level key takes the value found for it in `fragment`
|
||
/// ([`set_top_level_toml_line`]).
|
||
///
|
||
/// A managed table/key absent from `fragment` leaves `existing` untouched for it.
|
||
fn merge_managed_toml(
|
||
existing: &str,
|
||
managed_tables: &[String],
|
||
managed_keys: &[String],
|
||
fragment: &str,
|
||
) -> String {
|
||
let mut text = existing.to_owned();
|
||
for table in managed_tables {
|
||
if let Some(block) = extract_toml_table(fragment, table) {
|
||
text = replace_toml_table(&text, table, block.trim_end());
|
||
}
|
||
}
|
||
for key in managed_keys {
|
||
if let Some(line) = extract_top_level_toml_line(fragment, key) {
|
||
text = set_top_level_toml_line(&text, key, line);
|
||
}
|
||
}
|
||
if !text.ends_with('\n') {
|
||
text.push('\n');
|
||
}
|
||
text
|
||
}
|
||
|
||
/// Returns the full `key = …` line for `key` found at top level in `fragment`
|
||
/// (before any `[table]` header), or `None` if absent.
|
||
fn extract_top_level_toml_line<'a>(fragment: &'a str, key: &str) -> Option<&'a str> {
|
||
let needle = format!("{key} =");
|
||
for line in fragment.lines() {
|
||
let trimmed = line.trim_start();
|
||
if trimmed.starts_with('[') {
|
||
break;
|
||
}
|
||
if trimmed.starts_with(&needle) {
|
||
return Some(line);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Returns the `[table]` block (header + body up to the next header / EOF) for
|
||
/// `table` in `fragment`, or `None` if the table is absent.
|
||
fn extract_toml_table(fragment: &str, table: &str) -> Option<String> {
|
||
let header = format!("[{table}]");
|
||
let mut block: Vec<&str> = Vec::new();
|
||
let mut capturing = false;
|
||
for line in fragment.lines() {
|
||
let trimmed = line.trim();
|
||
if trimmed == header {
|
||
capturing = true;
|
||
block.push(line);
|
||
continue;
|
||
}
|
||
if capturing {
|
||
if trimmed.starts_with('[') {
|
||
break;
|
||
}
|
||
block.push(line);
|
||
}
|
||
}
|
||
if capturing {
|
||
Some(block.join("\n"))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn ensure_codex_trust(input: &str, path: &str) -> String {
|
||
let header = format!("[projects.{}]", toml_string(path));
|
||
if input.lines().any(|line| line.trim() == header) {
|
||
return input.to_owned();
|
||
}
|
||
append_block(input, &format!("{header}\ntrust_level = \"trusted\""))
|
||
}
|
||
|
||
fn replace_toml_table(input: &str, table: &str, block: &str) -> String {
|
||
let header = format!("[{table}]");
|
||
let mut out = Vec::new();
|
||
let mut skipping = false;
|
||
for line in input.lines() {
|
||
let trimmed = line.trim();
|
||
if trimmed == header {
|
||
skipping = true;
|
||
continue;
|
||
}
|
||
if skipping && trimmed.starts_with('[') {
|
||
skipping = false;
|
||
}
|
||
if !skipping {
|
||
out.push(line.to_owned());
|
||
}
|
||
}
|
||
append_block(&out.join("\n"), block)
|
||
}
|
||
|
||
/// Upserts a top-level `key` with a pre-formatted `replacement` line (already
|
||
/// `key = <toml value>`): replaces the existing top-level occurrence if present,
|
||
/// otherwise prepends it. The line-level core shared by [`merge_managed_toml`].
|
||
fn set_top_level_toml_line(input: &str, key: &str, replacement: &str) -> String {
|
||
let mut out = Vec::new();
|
||
let mut replaced = false;
|
||
let mut in_top_level = true;
|
||
for line in input.lines() {
|
||
let trimmed = line.trim_start();
|
||
if trimmed.starts_with('[') {
|
||
in_top_level = false;
|
||
}
|
||
if in_top_level && !replaced && trimmed.starts_with(&format!("{key} =")) {
|
||
out.push(replacement.to_owned());
|
||
replaced = true;
|
||
} else {
|
||
out.push(line.to_owned());
|
||
}
|
||
}
|
||
let text = out.join("\n");
|
||
if replaced {
|
||
text
|
||
} else {
|
||
prepend_line(&text, replacement)
|
||
}
|
||
}
|
||
|
||
fn prepend_line(input: &str, line: &str) -> String {
|
||
if input.trim().is_empty() {
|
||
format!("{line}\n")
|
||
} else {
|
||
format!("{line}\n{}", input.trim_start_matches('\n'))
|
||
}
|
||
}
|
||
|
||
fn append_block(input: &str, block: &str) -> String {
|
||
let trimmed = input.trim_end();
|
||
if trimmed.is_empty() {
|
||
format!("{}\n", block.trim_end())
|
||
} else {
|
||
format!("{trimmed}\n\n{}\n", block.trim_end())
|
||
}
|
||
}
|
||
|
||
/// 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 IdeA orchestration contract, 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.
|
||
///
|
||
/// The project's `memory` recall (index/hooks, ARCHITECTURE §14.5.4) is appended as
|
||
/// a `# Mémoire projet` section — one `- [Title](slug.md) — hook (type)` line per
|
||
/// entry, in the order given. When `memory` is empty the section is omitted
|
||
/// entirely, so an agent with no memory gets exactly the previous document.
|
||
///
|
||
/// The conversation `handoff` (lot P7, ARCHITECTURE §19) is appended **last**, as a
|
||
/// `# Reprise de la conversation` section carrying the optional objective and the
|
||
/// cumulative `summary_md`. Placed after the project memory (the most situational,
|
||
/// "where we left off" context an agent reads), it is omitted entirely when `handoff`
|
||
/// is `None`, so an agent with no handoff gets exactly the previous document.
|
||
///
|
||
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation.
|
||
///
|
||
/// The orchestration prose adapts to the agent's **surface** (cadrage v3, Décision
|
||
/// 3): when `mcp_enabled` is `true` (`profile.mcp.is_some()`), the agent sees the
|
||
/// native `idea_*` tools, so the prose points to those instead of the file
|
||
/// protocol — while keeping the **same** ban on the provider's native subagents.
|
||
/// When `false` (`mcp == None`) the prose is the **current `.ideai/requests`
|
||
/// wording, unchanged** (zero regression).
|
||
#[must_use]
|
||
pub(crate) fn compose_convention_file(
|
||
project_root: &str,
|
||
project_context: &str,
|
||
agent_md: &str,
|
||
skills: &[Skill],
|
||
memory: &[MemoryIndexEntry],
|
||
handoff: Option<&Handoff>,
|
||
mcp_enabled: bool,
|
||
) -> 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("# Orchestration IdeA\n\n");
|
||
if mcp_enabled {
|
||
// Surface MCP (cadrage v3, D3) : l'alternative native aux subagents est
|
||
// exposée comme outils typés `idea_*`. L'interdiction des subagents natifs
|
||
// du fournisseur est **conservée** ; seule la voie de délégation change.
|
||
out.push_str(
|
||
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
|
||
natifs du fournisseur IA. Utilise les outils IdeA natifs : \
|
||
`idea_ask_agent` (déléguer une tâche et recevoir la réponse), \
|
||
`idea_launch_agent` (lancer/réattacher un agent) et `idea_list_agents` \
|
||
(lister les agents du projet). IdeA lancera ou réattachera l'agent cible \
|
||
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
|
||
);
|
||
// Protocole de délégation (Option 1, B-5) : côté agent SOLLICITÉ. Une tâche
|
||
// déléguée arrive dans ton terminal préfixée `[IdeA · tâche de … · ticket …]`.
|
||
// Tu DOIS y répondre via l'outil `idea_reply`, jamais en texte libre — sinon
|
||
// l'agent qui t'a sollicité reste bloqué (sa réponse ne lui parviendra pas).
|
||
out.push_str(
|
||
"Quand tu reçois une tâche déléguée par IdeA (un message préfixé \
|
||
`[IdeA · tâche de … · ticket …]`), traite-la puis appelle \
|
||
**impérativement** l'outil `idea_reply(result=…)` pour rendre ton \
|
||
résultat. Ne réponds **jamais** uniquement en texte : seul `idea_reply` \
|
||
débloque l'agent qui t'a sollicité.\n\n",
|
||
);
|
||
} else {
|
||
out.push_str(
|
||
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
|
||
natifs du fournisseur IA. Écris une requête d'orchestration IdeA dans \
|
||
`.ideai/requests/<ton-agent>/` ; IdeA lancera ou réattachera l'agent cible \
|
||
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
|
||
);
|
||
}
|
||
out.push_str("---\n\n");
|
||
|
||
if !project_context.trim().is_empty() {
|
||
out.push_str("# Contexte projet\n\n");
|
||
out.push_str(project_context.trim());
|
||
out.push_str("\n\n---\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');
|
||
}
|
||
}
|
||
|
||
if !memory.is_empty() {
|
||
out.push_str("\n\n---\n\n# Mémoire projet\n\n");
|
||
for entry in memory {
|
||
out.push_str("- [");
|
||
out.push_str(&entry.title);
|
||
out.push_str("](");
|
||
out.push_str(".ideai/memory/");
|
||
out.push_str(entry.slug.as_str());
|
||
out.push_str(".md) — ");
|
||
out.push_str(&entry.hook);
|
||
out.push_str(" (");
|
||
out.push_str(memory_type_label(entry.r#type));
|
||
out.push_str(")\n");
|
||
}
|
||
}
|
||
|
||
// Reprise conversationnelle (lot P7) : section finale, la plus situationnelle
|
||
// (« où on en était »), placée après la mémoire projet. Omise sans handoff.
|
||
if let Some(handoff) = handoff {
|
||
out.push_str("\n\n---\n\n# Reprise de la conversation\n\n");
|
||
if let Some(objective) = &handoff.objective {
|
||
if !objective.trim().is_empty() {
|
||
out.push_str("**Objectif :** ");
|
||
out.push_str(objective.trim());
|
||
out.push_str("\n\n");
|
||
}
|
||
}
|
||
out.push_str(handoff.summary_md.as_str());
|
||
out.push('\n');
|
||
}
|
||
|
||
out
|
||
}
|
||
|
||
/// Renders a [`MemoryType`] as its stable lowercase label for the convention-file
|
||
/// memory section (`user`/`feedback`/`project`/`reference`).
|
||
#[must_use]
|
||
fn memory_type_label(kind: MemoryType) -> &'static str {
|
||
match kind {
|
||
MemoryType::User => "user",
|
||
MemoryType::Feedback => "feedback",
|
||
MemoryType::Project => "project",
|
||
MemoryType::Reference => "reference",
|
||
}
|
||
}
|
||
|
||
/// 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.",
|
||
&[],
|
||
&[],
|
||
None,
|
||
false,
|
||
);
|
||
|
||
// 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_includes_project_context_before_persona() {
|
||
let doc = compose_convention_file(
|
||
"/root",
|
||
"# Shared project context\n\nUse pnpm.",
|
||
"# Persona\n\nDo X.",
|
||
&[],
|
||
&[],
|
||
None,
|
||
false,
|
||
);
|
||
|
||
assert!(doc.contains("# Contexte projet"));
|
||
assert!(doc.contains("Use pnpm."));
|
||
let project_context_at = doc.find("# Contexte projet").unwrap();
|
||
let persona_at = doc.find("# Persona").unwrap();
|
||
assert!(
|
||
project_context_at < persona_at,
|
||
"shared project context must precede agent persona"
|
||
);
|
||
}
|
||
|
||
#[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"),
|
||
],
|
||
&[],
|
||
None,
|
||
false,
|
||
);
|
||
|
||
// 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"));
|
||
}
|
||
|
||
/// Builds a memory index entry for the convention-file composition tests.
|
||
fn mem(slug_str: &str, title: &str, hook: &str, kind: MemoryType) -> MemoryIndexEntry {
|
||
MemoryIndexEntry {
|
||
slug: domain::MemorySlug::new(slug_str).unwrap(),
|
||
title: title.to_owned(),
|
||
hook: hook.to_owned(),
|
||
r#type: kind,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn compose_convention_file_empty_memory_is_identical_to_no_memory() {
|
||
// An empty `memory` must yield exactly the previous document: no section,
|
||
// byte-for-byte identical to the no-skills/no-memory composition.
|
||
let with_empty =
|
||
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false);
|
||
assert!(
|
||
!with_empty.contains("# Mémoire projet"),
|
||
"no memory ⇒ no memory section"
|
||
);
|
||
// Same document whether or not we thread an empty slice (it already is the
|
||
// 4-arg call; this pins the omission contract explicitly).
|
||
assert_eq!(
|
||
with_empty,
|
||
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn compose_convention_file_appends_memory_entries_in_order() {
|
||
let doc = compose_convention_file(
|
||
"/root",
|
||
"",
|
||
"# Persona",
|
||
&[],
|
||
&[
|
||
mem("alpha-note", "Alpha", "the first hook", MemoryType::User),
|
||
mem(
|
||
"beta-note",
|
||
"Beta",
|
||
"the second hook",
|
||
MemoryType::Reference,
|
||
),
|
||
],
|
||
None,
|
||
false,
|
||
);
|
||
|
||
// Section present, after the persona.
|
||
assert!(doc.contains("# Mémoire projet"));
|
||
let persona_at = doc.find("# Persona").unwrap();
|
||
let section_at = doc.find("# Mémoire projet").unwrap();
|
||
assert!(persona_at < section_at, "memory comes after the persona");
|
||
|
||
// Exact line format: `- [Title](.ideai/memory/slug.md) — hook (type)`.
|
||
assert!(doc.contains("- [Alpha](.ideai/memory/alpha-note.md) — the first hook (user)"));
|
||
assert!(doc.contains("- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)"));
|
||
|
||
// Deterministic order: first entry precedes the second.
|
||
let alpha_at = doc.find("[Alpha]").unwrap();
|
||
let beta_at = doc.find("[Beta]").unwrap();
|
||
assert!(
|
||
alpha_at < beta_at,
|
||
"memory entries emitted in the given order"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn compose_convention_file_memory_and_skills_coexist() {
|
||
let skill = Skill::new(
|
||
domain::SkillId::from_uuid(uuid::Uuid::from_u128(1)),
|
||
"refactor",
|
||
MarkdownDoc::new("REFAC_BODY"),
|
||
domain::SkillScope::Global,
|
||
)
|
||
.unwrap();
|
||
let doc = compose_convention_file(
|
||
"/root",
|
||
"",
|
||
"# Persona",
|
||
std::slice::from_ref(&skill),
|
||
&[mem("note", "Note", "a hook", MemoryType::Project)],
|
||
None,
|
||
false,
|
||
);
|
||
|
||
// Both sections present.
|
||
assert!(doc.contains("# Skills"));
|
||
assert!(doc.contains("REFAC_BODY"));
|
||
assert!(doc.contains("# Mémoire projet"));
|
||
assert!(doc.contains("- [Note](.ideai/memory/note.md) — a hook (project)"));
|
||
|
||
// Skills section precedes the memory section (persona → skills → memory).
|
||
let skills_at = doc.find("# Skills").unwrap();
|
||
let memory_at = doc.find("# Mémoire projet").unwrap();
|
||
assert!(skills_at < memory_at, "skills come before memory");
|
||
}
|
||
|
||
#[test]
|
||
fn compose_convention_file_mcp_prose_points_to_idea_tools_and_keeps_subagent_ban() {
|
||
// mcp_enabled = true ⇒ prose exposes the native `idea_*` tools while keeping
|
||
// the ban on the provider's native subagents (cadrage v3, Décision 3).
|
||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
|
||
|
||
// Native IdeA orchestration tools surfaced.
|
||
assert!(
|
||
doc.contains("idea_ask_agent"),
|
||
"MCP prose must mention idea_ask_agent"
|
||
);
|
||
assert!(doc.contains("idea_launch_agent"));
|
||
assert!(doc.contains("idea_list_agents"));
|
||
// Native-subagent ban preserved.
|
||
assert!(
|
||
doc.contains("n'utilise jamais les subagents"),
|
||
"MCP prose must keep the native-subagent ban"
|
||
);
|
||
// It does NOT fall back to the file protocol wording.
|
||
assert!(
|
||
!doc.contains(".ideai/requests"),
|
||
"MCP prose must not point to the file protocol"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn compose_convention_file_mcp_prose_carries_the_idea_reply_delegation_protocol() {
|
||
// B-5 — the solicited-agent side of the protocol: a delegated task arrives as
|
||
// `[IdeA · tâche …]` and MUST be answered via `idea_reply`, never plain text.
|
||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
|
||
assert!(
|
||
doc.contains("idea_reply"),
|
||
"MCP prose must instruct answering via idea_reply"
|
||
);
|
||
assert!(
|
||
doc.contains("[IdeA · tâche"),
|
||
"MCP prose must describe the delegated-task prefix it answers to"
|
||
);
|
||
// Negative: the non-MCP (file-protocol) prose must NOT carry the idea_reply
|
||
// instruction (zero regression on the file path).
|
||
let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
|
||
assert!(!file_doc.contains("idea_reply"));
|
||
}
|
||
|
||
#[test]
|
||
fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() {
|
||
// mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged,
|
||
// and crucially WITHOUT the `idea_*` tools (zero regression).
|
||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
|
||
|
||
assert!(
|
||
doc.contains(".ideai/requests"),
|
||
"non-MCP prose must point to the file protocol"
|
||
);
|
||
assert!(
|
||
doc.contains("n'utilise jamais les subagents"),
|
||
"non-MCP prose keeps the native-subagent ban"
|
||
);
|
||
// No native MCP tools leaked into the file-protocol prose.
|
||
assert!(
|
||
!doc.contains("idea_ask_agent"),
|
||
"non-MCP prose must not mention the idea_* tools"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mcp_declaration_with_runtime_points_the_bridge_at_the_real_endpoint() {
|
||
// B-0 — a PTY-launched MCP-capable CLI (Claude/Codex REPL) auto-discovers the
|
||
// `.mcp.json` in its cwd (the run dir). When the composition root injects the
|
||
// real `McpRuntime`, the declaration written there must spawn *this* IdeA exe
|
||
// in `mcp-server` mode and dial the project's exact loopback endpoint — the
|
||
// same source of truth `ensure_mcp_server` binds — so the CLI can call
|
||
// `idea_list_agents` and get a reply end-to-end.
|
||
let rt = McpRuntime {
|
||
exe: "/opt/idea/idea".to_owned(),
|
||
endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(),
|
||
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
|
||
requester: "11112222-3333-4444-5555-666677778888".to_owned(),
|
||
};
|
||
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, Some(&rt));
|
||
|
||
// It is valid JSON with the IdeA server under `mcpServers/idea`.
|
||
let parsed: serde_json::Value =
|
||
serde_json::from_str(&decl).expect("declaration is valid JSON");
|
||
let idea = &parsed["mcpServers"]["idea"];
|
||
assert_eq!(idea["command"], "/opt/idea/idea");
|
||
let args = idea["args"].as_array().expect("args is an array");
|
||
let args: Vec<&str> = args.iter().filter_map(serde_json::Value::as_str).collect();
|
||
assert_eq!(args[0], "mcp-server");
|
||
// The real endpoint / project / requester are all threaded through.
|
||
assert!(args.contains(&"--endpoint"));
|
||
assert!(args.contains(&"/run/user/1000/idea-mcp/proj.sock"));
|
||
assert!(args.contains(&"--project"));
|
||
assert!(args.contains(&"0123456789abcdef0123456789abcdef"));
|
||
assert!(args.contains(&"--requester"));
|
||
assert_eq!(idea["transport"], "stdio");
|
||
}
|
||
|
||
#[test]
|
||
fn mcp_declaration_without_runtime_is_a_coherent_minimal_fallback() {
|
||
// Launches issued from inside `application` (orchestrator/hot-swap/tests) have
|
||
// no OS/runtime facts: the declaration falls back to the bare `idea mcp-server`
|
||
// command — still a valid, self-consistent `mcpServers/idea` entry.
|
||
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, None);
|
||
let parsed: serde_json::Value =
|
||
serde_json::from_str(&decl).expect("minimal declaration is valid JSON");
|
||
let idea = &parsed["mcpServers"]["idea"];
|
||
assert_eq!(idea["command"], "idea");
|
||
let args: Vec<&str> = idea["args"]
|
||
.as_array()
|
||
.expect("args array")
|
||
.iter()
|
||
.filter_map(serde_json::Value::as_str)
|
||
.collect();
|
||
assert_eq!(args, vec!["mcp-server"]);
|
||
// No endpoint/project/requester when no runtime was injected.
|
||
assert!(!args.contains(&"--endpoint"));
|
||
}
|
||
|
||
/// Builds a [`Handoff`] for the pure-section tests (lot P7).
|
||
fn handoff(summary: &str, objective: Option<&str>) -> Handoff {
|
||
Handoff::new(
|
||
summary,
|
||
domain::TurnId::from_uuid(uuid::Uuid::from_u128(1)),
|
||
objective.map(ToOwned::to_owned),
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn compose_convention_file_appends_handoff_section_after_memory_with_objective() {
|
||
// lot P7 — a handoff with an objective ⇒ a final `# Reprise de la conversation`
|
||
// section carrying the `**Objectif :**` line and the cumulative summary, placed
|
||
// AFTER the `# Mémoire projet` section.
|
||
let memory = [mem(
|
||
"git-optional",
|
||
"Git optionnel",
|
||
"git reste un simple tool",
|
||
MemoryType::Project,
|
||
)];
|
||
let h = handoff("Résumé : on a fini l'étape 2.", Some("Livrer le lot P7"));
|
||
let doc = compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), false);
|
||
|
||
assert!(
|
||
doc.contains("# Reprise de la conversation"),
|
||
"resume section present: {doc}"
|
||
);
|
||
assert!(
|
||
doc.contains("**Objectif :** Livrer le lot P7"),
|
||
"objective line present: {doc}"
|
||
);
|
||
assert!(
|
||
doc.contains("Résumé : on a fini l'étape 2."),
|
||
"summary present: {doc}"
|
||
);
|
||
|
||
// Ordering: the resume section comes AFTER the project memory.
|
||
let memory_at = doc.find("# Mémoire projet").unwrap();
|
||
let resume_at = doc.find("# Reprise de la conversation").unwrap();
|
||
assert!(
|
||
memory_at < resume_at,
|
||
"resume must follow the project memory: {doc}"
|
||
);
|
||
// And the objective line precedes the summary inside the section.
|
||
let objective_at = doc.find("**Objectif :**").unwrap();
|
||
let summary_at = doc.find("Résumé : on a fini").unwrap();
|
||
assert!(
|
||
resume_at < objective_at && objective_at < summary_at,
|
||
"objective then summary inside the section: {doc}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn compose_convention_file_handoff_without_objective_omits_objective_line() {
|
||
// objective = None ⇒ the section and the summary are present, but NO
|
||
// `**Objectif :**` line.
|
||
let h = handoff("Juste le résumé.", None);
|
||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), false);
|
||
|
||
assert!(
|
||
doc.contains("# Reprise de la conversation"),
|
||
"section present even without objective: {doc}"
|
||
);
|
||
assert!(doc.contains("Juste le résumé."), "summary present: {doc}");
|
||
assert!(
|
||
!doc.contains("**Objectif :**"),
|
||
"no objective line when objective is None: {doc}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn compose_convention_file_handoff_blank_objective_omits_objective_line() {
|
||
// objective = Some(" ") (whitespace only) ⇒ trimmed to empty ⇒ no objective
|
||
// line, but the section + summary still render.
|
||
let h = handoff("Le résumé.", Some(" "));
|
||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), false);
|
||
|
||
assert!(doc.contains("# Reprise de la conversation"));
|
||
assert!(doc.contains("Le résumé."));
|
||
assert!(
|
||
!doc.contains("**Objectif :**"),
|
||
"blank objective is omitted: {doc}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn compose_convention_file_without_handoff_omits_resume_section() {
|
||
// handoff = None ⇒ no `# Reprise de la conversation` section at all
|
||
// (zero regression for launches with no resume).
|
||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
|
||
assert!(
|
||
!doc.contains("# Reprise de la conversation"),
|
||
"no handoff ⇒ no resume section: {doc}"
|
||
);
|
||
assert!(
|
||
!doc.contains("**Objectif :**"),
|
||
"no handoff ⇒ no objective line: {doc}"
|
||
);
|
||
}
|
||
|
||
// NB (lot LP3-3): the former `claude_settings_seed_*` unit tests were removed —
|
||
// that translation now lives in `infrastructure::permission::ClaudePermissionProjector`
|
||
// and is covered by its own tests (LP3-2). Likewise the Codex sandbox/approval
|
||
// derivation moved to `CodexPermissionProjector`; `codex_config_toml` here is now
|
||
// **MCP-only**, so the Codex tests below assert only the MCP/trust merge.
|
||
|
||
#[test]
|
||
fn codex_config_trusts_run_and_project_and_replaces_only_idea_mcp() {
|
||
let existing = r#"[projects."/home/me/proj"]
|
||
trust_level = "trusted"
|
||
approval_policy = "nested"
|
||
|
||
[mcp_servers.idea]
|
||
command = "old"
|
||
|
||
[mcp_servers.other]
|
||
command = "other"
|
||
"#;
|
||
// No `permissions` argument anymore: the permission projection is decoupled
|
||
// (lot LP3-3) into `CodexPermissionProjector`. This function only merges MCP.
|
||
let rendered = codex_config_toml(
|
||
Some(existing),
|
||
"[mcp_servers.idea]\ncommand = \"new\"",
|
||
"/home/me/proj/.ideai/run/a",
|
||
"/home/me/proj",
|
||
);
|
||
|
||
// MCP table + trust entries are the only things this function touches.
|
||
assert!(rendered.contains("[projects.\"/home/me/proj\"]"));
|
||
assert!(rendered.contains("[projects.\"/home/me/proj/.ideai/run/a\"]"));
|
||
assert!(rendered.contains("approval_policy = \"nested\""));
|
||
assert!(rendered.contains("[mcp_servers.idea]\ncommand = \"new\""));
|
||
assert!(rendered.contains("[mcp_servers.other]\ncommand = \"other\""));
|
||
assert!(!rendered.contains("command = \"old\""));
|
||
assert_eq!(rendered.matches("[mcp_servers.idea]").count(), 1);
|
||
assert_eq!(rendered.matches("[projects.\"/home/me/proj\"]").count(), 1);
|
||
// Permission keys are NOT added by this MCP-only function.
|
||
assert!(!rendered.contains("sandbox_mode ="));
|
||
}
|
||
|
||
#[test]
|
||
fn codex_config_is_mcp_only_and_adds_no_permission_keys() {
|
||
let rendered = codex_config_toml(
|
||
None,
|
||
"[mcp_servers.idea]\ncommand = \"idea-mcp\"",
|
||
"/home/me/proj/.ideai/run/a",
|
||
"/home/me/proj",
|
||
);
|
||
|
||
assert!(!rendered.contains("approval_policy ="));
|
||
assert!(!rendered.contains("sandbox_mode ="));
|
||
assert!(rendered.contains("[projects.\"/home/me/proj\"]"));
|
||
assert!(rendered.contains("[projects.\"/home/me/proj/.ideai/run/a\"]"));
|
||
}
|
||
|
||
#[test]
|
||
fn merge_managed_toml_upserts_only_managed_keys() {
|
||
// Existing config (e.g. written by `apply_mcp_config`): MCP + trust + a user key.
|
||
let existing = r#"user_key = "keep-me"
|
||
|
||
[mcp_servers.idea]
|
||
command = "idea-mcp"
|
||
"#;
|
||
// Codex projector fragment: the two managed permission keys.
|
||
let fragment = "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n";
|
||
let merged = merge_managed_toml(
|
||
existing,
|
||
&[],
|
||
&["sandbox_mode".to_owned(), "approval_policy".to_owned()],
|
||
fragment,
|
||
);
|
||
|
||
// Managed keys are spliced in…
|
||
assert!(merged.contains("sandbox_mode = \"workspace-write\""));
|
||
assert!(merged.contains("approval_policy = \"never\""));
|
||
// …without disturbing the rest of the file.
|
||
assert!(merged.contains("user_key = \"keep-me\""));
|
||
assert!(merged.contains("[mcp_servers.idea]\ncommand = \"idea-mcp\""));
|
||
}
|
||
}
|