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(())
|
||||
|
||||
@ -207,6 +207,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
||||
profile.command.clone(),
|
||||
profile.args.clone(),
|
||||
cwd,
|
||||
seed,
|
||||
env.to_vec(),
|
||||
plan,
|
||||
enforcer,
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user