feat(persistence): P5 — ProviderSessionStore (resumable_id par provider)
Range le resumable_id du moteur par (conversation, provider) dans providers.json, support de reprise non exclusif (le handoff reste la source de fidélité au swap cross-profile) — §19.2/§19.3. - domaine : port ProviderSessionStore (get/set par provider) - infra : FsProviderSessionStore — providers.json map plate, set en read-modify-write atomique (tmp+rename) sérialisé par conversation (coexistence multi-providers garantie), get absent ⇒ None, corrompu ⇒ StoreError::Serialization Tests : 6 cas P5 (coexistence claude+codex, 12 set concurrents sans perte, corruption, isolation) ; cible conversation_log 30 verts, suites complètes sans régression. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -44,9 +44,11 @@ use domain::ports::StoreError;
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
mod handoff;
|
||||
mod providers;
|
||||
mod summarizer;
|
||||
|
||||
pub use handoff::FsHandoffStore;
|
||||
pub use providers::FsProviderSessionStore;
|
||||
pub use summarizer::{HeuristicHandoffSummarizer, WINDOW};
|
||||
|
||||
/// Dossier `.ideai/` à la racine d'un project root.
|
||||
|
||||
174
crates/infrastructure/src/conversation_log/providers.rs
Normal file
174
crates/infrastructure/src/conversation_log/providers.rs
Normal file
@ -0,0 +1,174 @@
|
||||
//! [`FsProviderSessionStore`] — l'adapter `tokio::fs` du port [`ProviderSessionStore`]
|
||||
//! (cadrage « persistance conversationnelle », lot P5).
|
||||
//!
|
||||
//! Le `resumable_id` **propre au moteur** (le `--resume`/`--continue` d'une CLI) est
|
||||
//! rangé **par (conversation, provider)** (ARCHITECTURE §19.2/§19.3) dans un seul fichier
|
||||
//! JSON par conversation : une **map `providerId → resumableId`** où plusieurs providers
|
||||
//! coexistent (après un swap Claude→Codex, on garde l'id de chacun) :
|
||||
//!
|
||||
//! ```text
|
||||
//! <project_root>/.ideai/conversations/
|
||||
//! └── <conversationId>/
|
||||
//! ├── log.jsonl # le log append-only (lot P2)
|
||||
//! ├── handoff.md # le dernier point de reprise (lot P3)
|
||||
//! └── providers.json # { "<providerId>": "<resumableId>", ... } (ce module)
|
||||
//! ```
|
||||
//!
|
||||
//! ## Format `providers.json`
|
||||
//!
|
||||
//! Un objet JSON plat `{ "claude": "abc-123", "codex": "def-456" }`. C'est l'unité
|
||||
//! écrite/relue : pas de wrapper, la clé est l'identifiant de profil/moteur.
|
||||
//!
|
||||
//! ## Robustesse
|
||||
//!
|
||||
//! - **Écriture atomique** : on écrit dans `providers.json.tmp` puis on `rename` —
|
||||
//! jamais de fichier à moitié écrit visible (même convention que [`super::FsHandoffStore`]).
|
||||
//! - **Lecture-modification-écriture** : `set` charge la map existante, insère/écrase la
|
||||
//! seule clé `provider_id`, puis réécrit — les autres providers ne sont jamais perdus.
|
||||
//! L'opération est **sérialisée par conversation** (un `Mutex` async par fichier, comme
|
||||
//! [`super::FsConversationLog`]) pour qu'un `set` concurrent ne perde pas l'écriture de
|
||||
//! l'autre (read-modify-write atomique du point de vue applicatif).
|
||||
//! - **Fichier ou clé absent** ⇒ `get` renvoie `Ok(None)` (jamais une erreur) : un
|
||||
//! provider sans session rangée est l'état normal au premier tour.
|
||||
//! - **Fichier présent mais illisible** (JSON corrompu) ⇒ [`StoreError::Serialization`].
|
||||
//! Contrairement au log JSONL (où une ligne corrompue est sautée), il n'y a qu'un
|
||||
//! enregistrement : on ne peut pas « sauter », c'est une vraie erreur.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::conversation::ConversationId;
|
||||
use domain::conversation_log::ProviderSessionStore;
|
||||
use domain::ports::StoreError;
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
use super::{CONVERSATIONS_DIR, IDEAI_DIR};
|
||||
|
||||
/// Nom du fichier des sessions par provider, par conversation.
|
||||
const PROVIDERS_FILE: &str = "providers.json";
|
||||
|
||||
/// Nom du fichier temporaire d'écriture atomique (renommé sur `providers.json`).
|
||||
const PROVIDERS_TMP_FILE: &str = "providers.json.tmp";
|
||||
|
||||
/// La map persistée : `providerId → resumableId`.
|
||||
type ProviderMap = HashMap<String, String>;
|
||||
|
||||
/// Adapter `tokio::fs` du store des `resumable_id`, un `providers.json` par conversation.
|
||||
///
|
||||
/// Même convention de construction que [`super::FsConversationLog`] / [`super::FsHandoffStore`] :
|
||||
/// le **project root** est fourni au constructeur, la base `<root>/.ideai/conversations`
|
||||
/// en dérive, et chaque conversation a son sous-dossier `<conversationId>/`.
|
||||
pub struct FsProviderSessionStore {
|
||||
/// Racine `<project_root>/.ideai/conversations`.
|
||||
base: PathBuf,
|
||||
/// Verrous d'écriture, un par fichier de conversation (sérialise le read-modify-write).
|
||||
write_locks: Mutex<HashMap<ConversationId, Arc<tokio::sync::Mutex<()>>>>,
|
||||
}
|
||||
|
||||
impl FsProviderSessionStore {
|
||||
/// Construit l'adapter à partir du **project root**.
|
||||
///
|
||||
/// La base `<root>/.ideai/conversations` en est dérivée ; le dossier de conversation
|
||||
/// est créé paresseusement au premier `set`.
|
||||
#[must_use]
|
||||
pub fn new(root: &ProjectPath) -> Self {
|
||||
let base = PathBuf::from(root.as_str())
|
||||
.join(IDEAI_DIR)
|
||||
.join(CONVERSATIONS_DIR);
|
||||
Self {
|
||||
base,
|
||||
write_locks: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<base>/<conversationId>` — le dossier d'une conversation.
|
||||
fn conversation_dir(&self, conversation: ConversationId) -> PathBuf {
|
||||
self.base.join(conversation.to_string())
|
||||
}
|
||||
|
||||
/// `<base>/<conversationId>/providers.json` — le fichier des sessions par provider.
|
||||
fn providers_path(&self, conversation: ConversationId) -> PathBuf {
|
||||
self.conversation_dir(conversation).join(PROVIDERS_FILE)
|
||||
}
|
||||
|
||||
/// `<base>/<conversationId>/providers.json.tmp` — le fichier temporaire d'écriture.
|
||||
fn providers_tmp_path(&self, conversation: ConversationId) -> PathBuf {
|
||||
self.conversation_dir(conversation).join(PROVIDERS_TMP_FILE)
|
||||
}
|
||||
|
||||
/// Renvoie (en le créant au besoin) le verrou d'écriture de `conversation`.
|
||||
fn write_lock(&self, conversation: ConversationId) -> Arc<tokio::sync::Mutex<()>> {
|
||||
self.write_locks
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.entry(conversation)
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Charge la map `providerId → resumableId` de `conversation`.
|
||||
///
|
||||
/// Fichier absent ⇒ map vide (jamais une erreur). JSON illisible ⇒
|
||||
/// [`StoreError::Serialization`].
|
||||
async fn load_map(&self, conversation: ConversationId) -> Result<ProviderMap, StoreError> {
|
||||
let bytes = match tokio::fs::read(self.providers_path(conversation)).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ProviderMap::new()),
|
||||
Err(e) => return Err(StoreError::Io(e.to_string())),
|
||||
};
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|e| StoreError::Serialization(format!("providers.json: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderSessionStore for FsProviderSessionStore {
|
||||
async fn get(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
provider_id: &str,
|
||||
) -> Result<Option<String>, StoreError> {
|
||||
let map = self.load_map(conversation).await?;
|
||||
Ok(map.get(provider_id).cloned())
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
provider_id: &str,
|
||||
resumable_id: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
// Read-modify-write sérialisé par conversation : le verrou est tenu le temps du
|
||||
// load + insert + write atomique, donc deux `set` concurrents (providers
|
||||
// différents ou non) s'ordonnent sans s'écraser.
|
||||
let lock = self.write_lock(conversation);
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
let mut map = self.load_map(conversation).await?;
|
||||
map.insert(provider_id.to_string(), resumable_id.to_string());
|
||||
|
||||
// Sérialiser **avant** toute I/O d'écriture : une erreur de sérialisation ne doit
|
||||
// pas laisser de fichier tmp partiel.
|
||||
let body =
|
||||
serde_json::to_vec(&map).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
|
||||
let dir = self.conversation_dir(conversation);
|
||||
tokio::fs::create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
|
||||
// Écriture atomique : écrire le tmp puis `rename` sur la cible. Un lecteur ne voit
|
||||
// jamais de fichier à moitié écrit (le rename est atomique sur le FS).
|
||||
let tmp = self.providers_tmp_path(conversation);
|
||||
tokio::fs::write(&tmp, &body)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
tokio::fs::rename(&tmp, self.providers_path(conversation))
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user