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:
@ -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(())
|
||||
|
||||
Reference in New Issue
Block a user