From 9b053216e328bad67367d118327a141604208478 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 12 Jun 2026 18:36:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(persistence):=20P8b=20=E2=80=94=20=C3=A9cr?= =?UTF-8?q?iture=20providers.json=20(resumable=20par=20provider)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Au lancement structuré, le resumable exposé par le moteur est rangé dans providers.json sous (id de paire, provider) — best-effort, n'altère jamais le lancement. - domaine : StructuredAdapter::provider_key() ⇒ "claude"/"codex" (clé stable par famille, lisible, indépendante de l'uuid d'instance) - application : port ProviderSessionProvider (root par appel) + with_provider_session_provider ; helper persist_provider_session dans launch_structured (skip si provider/adapter/id moteur absents ou set KO) - app-tauri : AppProviderSessionProvider (FsProviderSessionStore sur le root) - tests : 6 cas (nominal claude/codex, no-op sans provider/sans id moteur, best-effort, mapping provider_key) ; domain+application verts Lecture du store pour --resume = P8c. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/state.rs | 30 +- crates/application/src/agent/lifecycle.rs | 102 ++++++- crates/application/src/agent/mod.rs | 1 + crates/application/src/lib.rs | 2 +- .../application/tests/structured_launch_d3.rs | 282 ++++++++++++++++++ crates/domain/src/profile.rs | 18 ++ 6 files changed, 430 insertions(+), 5 deletions(-) diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 110a109..fb58f1f 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -42,7 +42,7 @@ use domain::{DomainEvent, EmbedderProfile, Project, ProjectId}; use infrastructure::{ embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime, EmbedderEnvProbe, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, - FsHandoffStore, FsMemoryStore, HeuristicHandoffSummarizer, + FsHandoffStore, FsMemoryStore, FsProviderSessionStore, HeuristicHandoffSummarizer, FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, SystemMillisClock, @@ -101,6 +101,25 @@ impl application::HandoffProvider for AppHandoffProvider { } } +/// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot +/// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du +/// lancement en cours. +/// +/// Jumeau stateless de [`AppHandoffProvider`] : [`LaunchAgent`] est unique pour tous +/// les projets, alors que `providers.json` est **par project root** +/// (`/.ideai/conversations/providers.json`). On construit donc un store frais +/// par lancement, ciblant le bon dossier. Sans état (zéro champ), partagé via `Arc`. +struct AppProviderSessionProvider; + +impl application::ProviderSessionProvider for AppProviderSessionProvider { + fn provider_session_store_for( + &self, + root: &domain::project::ProjectPath, + ) -> Option> { + Some(Arc::new(FsProviderSessionStore::new(root)) as Arc) + } +} + /// Everything the IPC layer needs at runtime, managed by Tauri. /// /// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete @@ -627,7 +646,14 @@ impl AppState { // porte une conversation et qu'un handoff existe (`/.ideai/conversations/`), // son résumé est réinjecté dans le convention file. Best-effort, additif : // un handoff absent/illisible ⇒ lancement normal sans section. - .with_handoff_provider(Arc::new(AppHandoffProvider) as Arc), + .with_handoff_provider(Arc::new(AppHandoffProvider) as Arc) + // Resumable moteur par provider (lot P8b) : après un lancement structuré + // exposant un id de session moteur, rangé sous la clé de paire dans + // `/.ideai/conversations/providers.json`. Best-effort, additif : une + // écriture en échec / pas d'id moteur ⇒ lancement normal, aucune écriture. + .with_provider_session_provider( + Arc::new(AppProviderSessionProvider) as Arc, + ), ); // Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/ diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 6a5bab6..6f1983d 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -21,8 +21,8 @@ use domain::ports::{ use domain::{ Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId, ConversationParty, DomainEvent, Handoff, HandoffStore, ManifestEntry, MarkdownDoc, - MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, ProjectPath, PtySize, SessionId, - SessionKind, SessionStatus, Skill, TerminalSession, + MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, ProjectPath, ProviderSessionStore, + PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession, }; use crate::error::AppError; @@ -61,6 +61,25 @@ pub trait HandoffProvider: Send + Sync { fn handoff_store_for(&self, root: &ProjectPath) -> Option>; } +/// 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>; +} + // --------------------------------------------------------------------------- // CreateAgentFromScratch // --------------------------------------------------------------------------- @@ -805,6 +824,13 @@ pub struct LaunchAgent { /// reprise (zéro régression pour les call sites/tests legacy). Un handoff /// absent/illisible ⇒ lancement normal, jamais d'échec. handoffs: Option>, + /// 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>, } impl LaunchAgent { @@ -839,9 +865,27 @@ impl LaunchAgent { session_factory: None, structured: None, handoffs: None, + provider_sessions: None, } } + /// 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, + ) -> 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 @@ -1224,6 +1268,7 @@ impl LaunchAgent { &run_dir, &session_plan, pair_conversation_id, + &input.project.root, input.node_id, size, ) @@ -1284,6 +1329,7 @@ impl LaunchAgent { run_dir: &ProjectPath, session_plan: &SessionPlan, pair_conversation_id: String, + root: &ProjectPath, node_id: Option, size: PtySize, ) -> Result { @@ -1309,6 +1355,20 @@ impl LaunchAgent { // 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 { @@ -1331,6 +1391,44 @@ impl LaunchAgent { }) } + /// 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). /// diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 97b361e..cda3258 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -26,6 +26,7 @@ pub use resume::{ pub use lifecycle::{ ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider, + ProviderSessionProvider, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index fa0f1dd..aeaf841 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -33,7 +33,7 @@ pub use agent::{ ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, - HandoffProvider, + HandoffProvider, ProviderSessionProvider, InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, McpRuntime, diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index 3416680..e17d0e5 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -538,6 +538,87 @@ impl AgentSessionFactory for FakeFactory { } } +// --------------------------------------------------------------------------- +// FakeProviderSessionStore + FakeProviderSessionProvider (lot P8b) +// --------------------------------------------------------------------------- + +use application::ProviderSessionProvider; +use domain::{ConversationId, ProviderSessionStore}; + +/// In-memory [`ProviderSessionStore`] for the P8b launch tests: a +/// `(conversation, provider_id) → resumable_id` map, observable after the launch. +/// `set` can be made to fail (best-effort scenario) so we can prove a write error +/// never degrades the launch. +#[derive(Clone, Default)] +struct FakeProviderSessionStore { + entries: Arc>>, + fail_set: bool, +} + +impl FakeProviderSessionStore { + fn new() -> Self { + Self::default() + } + /// A store whose `set` always errors (best-effort proof). + fn failing() -> Self { + Self { + entries: Arc::new(Mutex::new(HashMap::new())), + fail_set: true, + } + } + /// Observed resumable id for a `(conversation, provider)` couple, if any. + fn get_sync(&self, conversation: ConversationId, provider: &str) -> Option { + self.entries + .lock() + .unwrap() + .get(&(conversation, provider.to_owned())) + .cloned() + } + fn len(&self) -> usize { + self.entries.lock().unwrap().len() + } +} + +#[async_trait] +impl ProviderSessionStore for FakeProviderSessionStore { + async fn get( + &self, + conversation: ConversationId, + provider_id: &str, + ) -> Result, StoreError> { + Ok(self.get_sync(conversation, provider_id)) + } + async fn set( + &self, + conversation: ConversationId, + provider_id: &str, + resumable_id: &str, + ) -> Result<(), StoreError> { + if self.fail_set { + return Err(StoreError::Io("forced set failure".to_owned())); + } + self.entries + .lock() + .unwrap() + .insert((conversation, provider_id.to_owned()), resumable_id.to_owned()); + Ok(()) + } +} + +/// [`ProviderSessionProvider`] that hands back the same store for any root (the +/// launch tests use a single project root). +#[derive(Clone)] +struct FakeProviderSessionProvider(Arc); + +impl ProviderSessionProvider for FakeProviderSessionProvider { + fn provider_session_store_for( + &self, + _root: &ProjectPath, + ) -> Option> { + Some(Arc::clone(&self.0) as Arc) + } +} + // --------------------------------------------------------------------------- // Builders // --------------------------------------------------------------------------- @@ -1170,3 +1251,204 @@ async fn structured_profile_fresh_cell_resolves_to_none() { "fresh structured cell without a session block plans None" ); } + +// =========================================================================== +// LOT P8b — écriture de `providers.json` au lancement structuré +// +// Prouve `persist_provider_session` (best-effort) : après un lancement structuré +// exposant un id de session moteur, IdeA range `(pair, provider_key) → +// engine_session_id` via le `ProviderSessionStore` câblé. Toutes les conditions de +// skip (provider absent, id moteur absent) et la robustesse (set en échec) sont +// couvertes — le lancement réussit toujours. +// =========================================================================== + +/// Profil **structuré Codex** (porte `StructuredAdapter::Codex`) pour prouver la clé +/// provider `"codex"`. +fn structured_codex_profile(id: ProfileId) -> AgentProfile { + AgentProfile::new( + id, + "Codex Structuré", + "codex", + Vec::new(), + ContextInjection::convention_file("AGENTS.md").unwrap(), + Some("codex --version".to_owned()), + "{agentRunDir}", + None, + ) + .unwrap() + .with_structured_adapter(StructuredAdapter::Codex) +} + +/// Builds a `LaunchAgent.with_structured(...).with_provider_session_provider(...)` +/// over the given profile/factory, returning the launcher, the agent and the +/// observable provider store. When `provider` is `None`, no provider is wired +/// (legacy path). +fn launch_fixture_p8b( + profile: AgentProfile, + factory: FakeFactory, + store: Option>, +) -> (Arc, Agent) { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id); + let contexts = FakeContexts::with_agent(&agent, "# ctx body"); + let profiles = FakeProfiles::new(vec![profile]); + let runtime = FakeRuntime::new(); + let fs = FakeFs::default(); + let pty = FakePty::new(sid(777)); + let sessions = Arc::new(TerminalSessions::new()); + let structured = Arc::new(StructuredSessions::new()); + let bus = SpyBus::default(); + let mut launch = LaunchAgent::new( + Arc::new(contexts), + Arc::new(profiles), + Arc::new(runtime), + Arc::new(fs), + Arc::new(pty), + Arc::new(FakeSkills), + Arc::clone(&sessions), + Arc::new(bus), + Arc::new(SeqIds::new()), + Arc::new(FakeRecall), + None, + ) + .with_structured(Arc::new(factory), Arc::clone(&structured)); + if let Some(store) = store { + let provider = Arc::new(FakeProviderSessionProvider(store)) as Arc; + launch = launch.with_provider_session_provider(provider); + } + (Arc::new(launch), agent) +} + +/// **Cas nominal Claude** : provider câblé, profil structuré Claude, moteur exposant +/// `conversation_id() == Some("engine-xyz")`, cellule portant une `conversation_id` +/// UUID valide ⇒ le store contient `(pair, "claude") == "engine-xyz"`, où la clé de +/// conversation est exactement le `pair_conversation_id` (= l'uuid d'entrée). +#[tokio::test] +async fn p8b_nominal_claude_writes_engine_id_under_pair_and_claude_key() { + let store = Arc::new(FakeProviderSessionStore::new()); + let factory = FakeFactory::new(500, Some("engine-xyz")); + let (launch, agent) = + launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + + // La cellule porte un id de paire UUID valide : c'est lui qui sert de clé. + let pair = ConversationId::from_uuid(Uuid::from_u128(123)); + let mut input = launch_input(agent.id); + input.conversation_id = Some(pair.to_string()); + + launch.execute(input).await.expect("structured launch"); + + assert_eq!( + store.get_sync(pair, "claude").as_deref(), + Some("engine-xyz"), + "engine resumable stored under (pair, claude)" + ); + assert_eq!(store.len(), 1, "exactly one entry written"); + // La clé de conversation est bien le pair_conversation_id (l'uuid d'entrée), pas + // l'id moteur : un lookup sous l'id moteur (non-UUID) ne renverrait rien. + assert!( + store.get_sync(pair, "codex").is_none(), + "nothing written under a different provider key" + ); +} + +/// **Cas nominal Codex** : même scénario, profil structuré Codex ⇒ clé provider +/// `"codex"`. +#[tokio::test] +async fn p8b_nominal_codex_writes_engine_id_under_codex_key() { + let store = Arc::new(FakeProviderSessionStore::new()); + let factory = FakeFactory::new(500, Some("engine-codex")); + let (launch, agent) = launch_fixture_p8b( + structured_codex_profile(pid(9)), + factory, + Some(Arc::clone(&store)), + ); + + let pair = ConversationId::from_uuid(Uuid::from_u128(456)); + let mut input = launch_input(agent.id); + input.conversation_id = Some(pair.to_string()); + + launch.execute(input).await.expect("structured launch"); + + assert_eq!( + store.get_sync(pair, "codex").as_deref(), + Some("engine-codex"), + "engine resumable stored under (pair, codex)" + ); + assert_eq!(store.len(), 1); +} + +/// **provider_key correct** : test direct du contrat de persistance. +#[test] +fn p8b_provider_key_mapping_is_stable() { + assert_eq!(StructuredAdapter::Claude.provider_key(), "claude"); + assert_eq!(StructuredAdapter::Codex.provider_key(), "codex"); +} + +/// **No-op sans provider** : sans `with_provider_session_provider`, le lancement +/// réussit et rien n'est écrit (le store, non câblé, reste vide — on en garde une +/// copie pour le prouver). +#[tokio::test] +async fn p8b_no_provider_wired_writes_nothing_launch_ok() { + let store = Arc::new(FakeProviderSessionStore::new()); + let factory = FakeFactory::new(500, Some("engine-xyz")); + // store NON câblé (None) ⇒ aucune écriture possible. + let (launch, agent) = launch_fixture_p8b(structured_profile(pid(9)), factory, None); + + let pair = ConversationId::from_uuid(Uuid::from_u128(123)); + let mut input = launch_input(agent.id); + input.conversation_id = Some(pair.to_string()); + + launch.execute(input).await.expect("launch ok without provider"); + + assert_eq!(store.len(), 0, "no provider wired ⇒ nothing written"); +} + +/// **No-op sans id moteur** : la session fake n'expose aucun id +/// (`conversation_id() == None`) ⇒ aucune écriture, le lancement réussit. +#[tokio::test] +async fn p8b_no_engine_id_writes_nothing_launch_ok() { + let store = Arc::new(FakeProviderSessionStore::new()); + // Factory avec conversation_id None ⇒ session.conversation_id() == None. + let factory = FakeFactory::new(500, None); + let (launch, agent) = + launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + + let pair = ConversationId::from_uuid(Uuid::from_u128(123)); + let mut input = launch_input(agent.id); + input.conversation_id = Some(pair.to_string()); + + launch.execute(input).await.expect("launch ok without engine id"); + + assert_eq!( + store.len(), + 0, + "no engine session id ⇒ persist skipped, launch unaffected" + ); +} + +/// **Best-effort** : un store dont `set` renvoie `Err` ⇒ `execute` réussit quand +/// même (la sortie de lancement n'est pas dégradée : descripteur structuré présent, +/// engine_session_id exposé). +#[tokio::test] +async fn p8b_store_set_error_does_not_fail_launch() { + let store = Arc::new(FakeProviderSessionStore::failing()); + let factory = FakeFactory::new(500, Some("engine-xyz")); + let (launch, agent) = + launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + + let pair = ConversationId::from_uuid(Uuid::from_u128(123)); + let mut input = launch_input(agent.id); + input.conversation_id = Some(pair.to_string()); + + // Le lancement réussit malgré l'échec d'écriture du resumable. + let out = launch + .execute(input) + .await + .expect("launch must succeed despite store set failure"); + + // Sortie non dégradée : descripteur structuré + id moteur toujours exposés. + let desc = out.structured.expect("structured descriptor still present"); + assert_eq!(desc.session_id, sid(500)); + assert_eq!(out.engine_session_id.as_deref(), Some("engine-xyz")); + // Le store n'a (logiquement) rien persisté. + assert_eq!(store.len(), 0, "failing set wrote nothing"); +} diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index dbd8e7e..45505fe 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -134,6 +134,24 @@ pub enum StructuredAdapter { Codex, } +impl StructuredAdapter { + /// Clé **stable par famille de moteur** (provider), indépendante de l'uuid + /// d'instance d'un profil. Sert de clé dans `providers.json` (lot P8b) : un swap + /// d'un profil vers un autre profil de **la même famille** réutilise la même + /// entrée (resumable partagé), et le fichier reste lisible à l'œil. + /// + /// Mapping : `Claude → "claude"`, `Codex → "codex"` (aligné sur la sérialisation + /// camelCase de l'enum, mais figé ici comme **contrat de persistance** : ce + /// mapping ne doit pas dériver si la représentation serde change). + #[must_use] + pub const fn provider_key(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Codex => "codex", + } + } +} + /// Transport du serveur MCP IdeA exposé à une CLI (détail invisible au domaine). /// /// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert).