//! [`StructuredSessionFactory`] — la fabrique [`AgentSessionFactory`] qui **route un //! profil vers le bon adapter** structuré (ARCHITECTURE §17.2) selon //! `profile.structured_adapter` (§17.3). Agrège Claude + Codex derrière une seule //! surface ; aucun type concret ne franchit la frontière domaine (seuls //! `Arc` sortent). //! //! Open/Closed : ajouter un moteur structuré = ajouter un adapter + une variante //! [`StructuredAdapter`] + un bras de `match` ici. Le cœur ne bouge pas. use std::sync::Arc; use async_trait::async_trait; use domain::ports::{ AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan, }; use domain::profile::{AgentProfile, StructuredAdapter}; use domain::project::ProjectPath; use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::SessionId; use super::claude::ClaudeSdkSession; use super::codex::CodexExecSession; /// Fabrique infra des sessions structurées, sélectionnée par le profil. /// /// Quasi sans état : elle instancie l'adapter au vol depuis le profil (le binaire à /// lancer = `profile.command`), de sorte qu'un seul exemplaire injecté au /// composition root sert tous les agents (jumeau de `CliAgentRuntime`). Le seul état /// porté est l'**enforcer de sandbox OS** optionnel (lot LP4-4), injecté **par /// instance** au composition root (jumeau de `PortablePtyAdapter::with_sandbox_enforcer`) /// et apparié au plan **par lancement** dans [`start`](AgentSessionFactory::start). #[derive(Clone, Default)] pub struct StructuredSessionFactory { /// Enforcer OS optionnel passé aux adapters structurés. `None` ⇒ aucun /// sandboxing (chemin natif inchangé, zéro régression). sandbox_enforcer: Option>, } impl StructuredSessionFactory { /// Construit la fabrique (sans enforcer : chemin natif). #[must_use] pub fn new() -> Self { Self { sandbox_enforcer: None, } } /// Builder additif : câble un [`SandboxEnforcer`] OS (lot LP4-4). Jumeau exact de /// [`crate::PortablePtyAdapter::with_sandbox_enforcer`]. Avec lui, tout lancement /// structuré dont le plan (`SpawnSpec.sandbox`) est `Some` voit ce plan appliqué /// sur l'enfant. Sans lui (défaut), aucun tour n'est sandboxé. #[must_use] pub fn with_sandbox_enforcer(mut self, enforcer: Arc) -> Self { self.sandbox_enforcer = Some(enforcer); self } } /// Dérive l'[`SessionPlan`] le `seed` de reprise : seul [`SessionPlan::Resume`] /// amorce l'adapter avec un id de conversation existant ; `Assign`/`None` partent /// d'une conversation neuve (l'id sera capté au premier tour). fn seed_conversation_id(session: &SessionPlan) -> Option { match session { SessionPlan::Resume { conversation_id } => Some(conversation_id.clone()), SessionPlan::None | SessionPlan::Assign { .. } => None, } } #[async_trait] impl AgentSessionFactory for StructuredSessionFactory { fn supports(&self, profile: &AgentProfile) -> bool { // Source unique de vérité (§17.3, D7) : sélectionnabilité = pilotable en // structuré. `is_selectable` et `supports` partagent ainsi le **même** // prédicat de domaine, ils ne peuvent pas diverger. profile.is_selectable() } async fn start( &self, profile: &AgentProfile, ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, sandbox: Option<&SandboxPlan>, ) -> Result, AgentSessionError> { let adapter = profile.structured_adapter.ok_or_else(|| { AgentSessionError::Start(format!( "le profil « {} » n'a pas d'adapter structuré", profile.name )) })?; let id = SessionId::new_random(); let command = profile.command.clone(); let cwd = cwd.as_str().to_owned(); let seed = seed_conversation_id(session); // Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par // instance** (champ). Tous deux sont relayés à l'adapter, qui remplira // `SpawnLine.sandbox` et passera l'enforcer à `run_turn`. `plan == None` ⇒ // l'adapter reste sur le drain async natif. let plan = sandbox.cloned(); let enforcer = self.sandbox_enforcer.clone(); // NOTE : le contexte (`_ctx`) est injecté par `LaunchAgent` (D3) via le // convention file dans le run dir *avant* l'appel à la factory (le `.md` est // déjà écrit) ; l'adapter n'a donc qu'à lancer la CLI dans ce cwd. Aucune // injection supplémentaire n'incombe ici en mode structuré (la CLI lit son // fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd). let session: Arc = match adapter { StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new( id, command, cwd, seed, plan, enforcer, )), StructuredAdapter::Codex => Arc::new(CodexExecSession::new( id, command, cwd, seed, vec![ctx.project_root.clone()], plan, enforcer, )), }; Ok(session) } }