chantier: durcissement reprise headless inter-projets + persistance profils Opencode

- Implémentation reprise de session Opencode headless (conversational_recovery)
- Persistance des profils IA Opencode dans .ideai/memory avec JSON Schema
- Gestion des permissions MCP pour agents externes
- Tests QA verts : structured_launch_d3, conversation_log, tickets_missing_carnet, agents, ProfilesSettings
This commit is contained in:
2026-07-28 12:23:34 +02:00
parent 78f7c8fe2d
commit 045e0984cc
15 changed files with 782 additions and 49 deletions

View File

@ -62,8 +62,11 @@ type ProviderMap = HashMap<String, String>;
/// 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 projet canonique. `None` si le root fourni n'est pas résoluble :
/// le store devient alors fail-closed (lecture `None`, écriture `Err`).
root: Option<PathBuf>,
/// Racine `<project_root>/.ideai/conversations`.
base: PathBuf,
base: Option<PathBuf>,
/// Verrous d'écriture, un par fichier de conversation (sérialise le read-modify-write).
write_locks: Mutex<HashMap<ConversationId, Arc<tokio::sync::Mutex<()>>>>,
}
@ -75,28 +78,48 @@ impl FsProviderSessionStore {
/// 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);
let root = std::fs::canonicalize(root.as_str()).ok();
let base = root
.as_ref()
.map(|root| root.join(IDEAI_DIR).join(CONVERSATIONS_DIR));
Self {
root,
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())
fn conversation_dir(&self, conversation: ConversationId) -> Result<PathBuf, StoreError> {
let Some(root) = self.root.as_ref() else {
return Err(StoreError::Io(
"project root canonique indisponible pour providers.json".to_owned(),
));
};
let Some(base) = self.base.as_ref() else {
return Err(StoreError::Io(
"base providers.json indisponible".to_owned(),
));
};
let dir = base.join(conversation.to_string());
if !dir.starts_with(root) {
return Err(StoreError::Io(
"chemin providers.json hors du project root canonique".to_owned(),
));
}
Ok(dir)
}
/// `<base>/<conversationId>/providers.json` — le fichier des sessions par provider.
fn providers_path(&self, conversation: ConversationId) -> PathBuf {
self.conversation_dir(conversation).join(PROVIDERS_FILE)
fn providers_path(&self, conversation: ConversationId) -> Result<PathBuf, StoreError> {
Ok(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)
fn providers_tmp_path(&self, conversation: ConversationId) -> Result<PathBuf, StoreError> {
Ok(self
.conversation_dir(conversation)?
.join(PROVIDERS_TMP_FILE))
}
/// Renvoie (en le créant au besoin) le verrou d'écriture de `conversation`.
@ -114,7 +137,11 @@ impl FsProviderSessionStore {
/// 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 {
let path = match self.providers_path(conversation) {
Ok(path) => path,
Err(_) => return Ok(ProviderMap::new()),
};
let bytes = match tokio::fs::read(path).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())),
@ -155,18 +182,18 @@ impl ProviderSessionStore for FsProviderSessionStore {
let body =
serde_json::to_vec(&map).map_err(|e| StoreError::Serialization(e.to_string()))?;
let dir = self.conversation_dir(conversation);
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);
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))
tokio::fs::rename(&tmp, self.providers_path(conversation)?)
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
Ok(())