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(())

View File

@ -207,6 +207,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
profile.command.clone(),
profile.args.clone(),
cwd,
seed,
env.to_vec(),
plan,
enforcer,

View File

@ -7,6 +7,7 @@
use std::process::Stdio;
use std::sync::Arc;
use std::sync::Mutex;
use async_trait::async_trait;
use serde_json::Value;
@ -32,6 +33,24 @@ pub enum ParsedEvent {
Ignored,
}
/// Identifiant natif de session OpenCode exposé dans les événements JSONL.
pub fn extract_session_id(line: &str) -> Result<Option<String>, AgentSessionError> {
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(None);
}
let value: Value = serde_json::from_str(trimmed)
.map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?;
Ok(value
.get("sessionID")
.or_else(|| value.get("session_id"))
.or_else(|| value.get("sessionId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_owned))
}
/// Message d'erreur par défaut quand `error` n'est ni une chaîne ni un objet exploitable.
const OPENCODE_ERROR_FALLBACK: &str = "OpenCode a signalé une erreur sans détail exploitable";
@ -190,6 +209,7 @@ pub struct OpenCodeSession {
prefix_args: Vec<String>,
cwd: String,
env: Vec<(String, String)>,
engine_session_id: Mutex<Option<String>>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
}
@ -202,6 +222,7 @@ impl OpenCodeSession {
command_prefix: impl Into<String>,
profile_args: Vec<String>,
cwd: impl Into<String>,
seed: Option<String>,
env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
@ -215,6 +236,7 @@ impl OpenCodeSession {
prefix_args: prefix,
cwd: cwd.into(),
env,
engine_session_id: Mutex::new(seed),
sandbox,
sandbox_enforcer,
})
@ -222,14 +244,20 @@ impl OpenCodeSession {
fn build_args(&self, prompt: &str) -> Vec<String> {
let mut args = self.prefix_args.clone();
args.extend([
"run".to_owned(),
"--format".to_owned(),
"json".to_owned(),
prompt.to_owned(),
]);
args.extend(["run".to_owned(), "--format".to_owned(), "json".to_owned()]);
if let Some(engine_id) = self.conversation_id() {
args.extend(["--session".to_owned(), engine_id]);
}
args.push(prompt.to_owned());
args
}
fn capture_session_id(&self, engine_id: String) {
*self
.engine_session_id
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(engine_id);
}
}
#[async_trait]
@ -239,7 +267,10 @@ impl AgentSession for OpenCodeSession {
}
fn conversation_id(&self) -> Option<String> {
None
self.engine_session_id
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
@ -277,6 +308,9 @@ impl AgentSession for OpenCodeSession {
.await
.map_err(|e| AgentSessionError::Io(e.to_string()))?
{
if let Some(engine_id) = extract_session_id(&line)? {
self.capture_session_id(engine_id);
}
collected.push(line);
}
@ -512,6 +546,7 @@ exit 1
script.to_string_lossy(),
Vec::new(),
tmp.path().to_string_lossy(),
None,
Vec::new(),
None,
None,
@ -534,4 +569,109 @@ exit 1
]
);
}
#[test]
fn extract_session_id_accepts_opencode_jsonl_session_id_variants() {
assert_eq!(
extract_session_id(r#"{"type":"step_start","sessionID":"s1"}"#).unwrap(),
Some("s1".to_owned())
);
assert_eq!(
extract_session_id(r#"{"type":"step_start","session_id":"s2"}"#).unwrap(),
Some("s2".to_owned())
);
assert_eq!(
extract_session_id(r#"{"type":"step_start","sessionId":"s3"}"#).unwrap(),
Some("s3".to_owned())
);
assert_eq!(
extract_session_id(r#"{"type":"step_start","sessionID":" "}"#).unwrap(),
None
);
}
#[cfg(unix)]
#[tokio::test]
async fn send_captures_engine_session_id_from_jsonl() {
let tmp = TempDir::new("capture-session");
let script = tmp.path().join("opencode-fixture.sh");
fs::write(
&script,
r#"#!/bin/sh
printf '%s\n' '{"type":"step_start","sessionID":"opencode-engine-1"}'
printf '%s\n' '{"type":"text","sessionID":"opencode-engine-1","part":{"text":"ok"}}'
printf '%s\n' '{"type":"step_finish","sessionID":"opencode-engine-1"}'
"#,
)
.unwrap();
let mut permissions = fs::metadata(&script).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script, permissions).unwrap();
let session = OpenCodeSession::new(
SessionId::new_random(),
script.to_string_lossy(),
Vec::new(),
tmp.path().to_string_lossy(),
None,
Vec::new(),
None,
None,
)
.unwrap();
assert_eq!(session.conversation_id(), None);
let events = session.send("prompt").await.unwrap().collect::<Vec<_>>();
assert!(matches!(events.last(), Some(ReplyEvent::Final { .. })));
assert_eq!(
session.conversation_id().as_deref(),
Some("opencode-engine-1")
);
}
#[cfg(unix)]
#[tokio::test]
async fn seeded_or_captured_session_id_is_passed_as_opencode_session_arg() {
let tmp = TempDir::new("resume-arg");
let script = tmp.path().join("opencode-fixture.sh");
let args_file = tmp.path().join("args.txt");
fs::write(
&script,
format!(
r#"#!/bin/sh
printf '%s\n' "$@" > '{}'
printf '%s\n' '{{"type":"step_start","sessionID":"seeded-engine"}}'
printf '%s\n' '{{"type":"text","sessionID":"seeded-engine","part":{{"text":"ok"}}}}'
printf '%s\n' '{{"type":"step_finish","sessionID":"seeded-engine"}}'
"#,
args_file.display()
),
)
.unwrap();
let mut permissions = fs::metadata(&script).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script, permissions).unwrap();
let session = OpenCodeSession::new(
SessionId::new_random(),
script.to_string_lossy(),
Vec::new(),
tmp.path().to_string_lossy(),
Some("seeded-engine".to_owned()),
Vec::new(),
None,
None,
)
.unwrap();
session.send("prompt").await.unwrap().for_each(drop);
let args = fs::read_to_string(args_file).unwrap();
let argv: Vec<&str> = args.lines().collect();
assert!(
argv.windows(2)
.any(|pair| pair == ["--session", "seeded-engine"]),
"OpenCode resume must use `--session <engine id>`, got: {argv:?}"
);
assert_eq!(argv.last(), Some(&"prompt"));
}
}