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:
@ -18,12 +18,15 @@ use std::path::PathBuf;
|
||||
|
||||
use domain::conversation::ConversationId;
|
||||
use domain::conversation_log::{
|
||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer, TurnId, TurnRole,
|
||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
ProviderSessionStore, TurnId, TurnRole,
|
||||
};
|
||||
use domain::input::InputSource;
|
||||
use domain::ports::StoreError;
|
||||
use domain::project::ProjectPath;
|
||||
use infrastructure::{FsConversationLog, FsHandoffStore, HeuristicHandoffSummarizer, WINDOW};
|
||||
use infrastructure::{
|
||||
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer, WINDOW,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -62,6 +65,14 @@ impl TempDir {
|
||||
fn handoff_tmp_path(&self, conversation: ConversationId) -> PathBuf {
|
||||
self.conversation_dir(conversation).join("handoff.md.tmp")
|
||||
}
|
||||
/// `<root>/.ideai/conversations/<conversationId>/providers.json` — the per-provider sessions (P5).
|
||||
fn providers_path(&self, conversation: ConversationId) -> PathBuf {
|
||||
self.conversation_dir(conversation).join("providers.json")
|
||||
}
|
||||
/// `<root>/.ideai/conversations/<conversationId>/providers.json.tmp` — the atomic-write tmp (P5).
|
||||
fn providers_tmp_path(&self, conversation: ConversationId) -> PathBuf {
|
||||
self.conversation_dir(conversation).join("providers.json.tmp")
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
@ -761,3 +772,228 @@ async fn fold_flattens_multiline_and_marker_like_text_into_one_line() {
|
||||
assert_eq!(lines2[2], "- **Response:** suite");
|
||||
assert_eq!(h2.up_to, turn_id(3));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// P5 — FsProviderSessionStore (providers.json): le `resumable_id` rangé par
|
||||
// (conversation, provider). Mêmes helpers L2 que P2/P3/P4 — `TempDir` maison
|
||||
// (chemin absolu pour `ProjectPath::new`), constructeurs déterministes
|
||||
// `conv_id`/`turn_id`, convention de dossier `<root>/.ideai/conversations/<id>/`.
|
||||
//
|
||||
// Format verrouillé : objet JSON plat `{ "<providerId>": "<resumableId>", ... }`.
|
||||
// Contrat : get/set par provider ; coexistence multi-providers sans perte ;
|
||||
// absent ⇒ Ok(None) ; corrompu ⇒ Err(Serialization) ; read-modify-write atomique
|
||||
// (tmp+rename, mutex par conversation) ; isolation par ConversationId.
|
||||
// ===========================================================================
|
||||
|
||||
/// Écrit un `providers.json` brut (corruption volontaire), créant le dossier au besoin.
|
||||
fn write_raw_providers(tmp: &TempDir, c: ConversationId, content: &str) {
|
||||
std::fs::create_dir_all(tmp.conversation_dir(c)).unwrap();
|
||||
std::fs::write(tmp.providers_path(c), content).unwrap();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get/set par provider + round-trip disque via une nouvelle instance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_set_then_get_round_trips_across_a_fresh_instance() {
|
||||
let tmp = TempDir::new();
|
||||
let c = conv_id(1);
|
||||
|
||||
// Première instance : set puis get immédiat.
|
||||
{
|
||||
let store = FsProviderSessionStore::new(&tmp.project_path());
|
||||
store.set(c, "claude", "a").await.unwrap();
|
||||
assert_eq!(
|
||||
store.get(c, "claude").await.unwrap(),
|
||||
Some("a".to_string()),
|
||||
"get doit rendre la valeur posée"
|
||||
);
|
||||
}
|
||||
|
||||
// Le fichier existe réellement sur disque, au bon emplacement.
|
||||
assert!(
|
||||
tmp.providers_path(c).exists(),
|
||||
"providers.json doit exister après set"
|
||||
);
|
||||
|
||||
// Une nouvelle instance sur le même root relit la persistance (aucun cache mémoire).
|
||||
let reborn = FsProviderSessionStore::new(&tmp.project_path());
|
||||
assert_eq!(
|
||||
reborn.get(c, "claude").await.unwrap(),
|
||||
Some("a".to_string()),
|
||||
"persistance réelle : relue via une instance neuve"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// coexistence multi-providers : set claude + codex => les deux ; re-set claude
|
||||
// n'efface pas codex ; sur disque les deux clés sont présentes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_set_keeps_other_providers_and_disk_holds_both_keys() {
|
||||
let tmp = TempDir::new();
|
||||
let store = FsProviderSessionStore::new(&tmp.project_path());
|
||||
let c = conv_id(2);
|
||||
|
||||
store.set(c, "claude", "a").await.unwrap();
|
||||
store.set(c, "codex", "b").await.unwrap();
|
||||
|
||||
// Les deux providers rendent leur valeur.
|
||||
assert_eq!(store.get(c, "claude").await.unwrap(), Some("a".to_string()));
|
||||
assert_eq!(store.get(c, "codex").await.unwrap(), Some("b".to_string()));
|
||||
|
||||
// Re-set claude (read-modify-write) ne doit PAS effacer codex.
|
||||
store.set(c, "claude", "a2").await.unwrap();
|
||||
assert_eq!(
|
||||
store.get(c, "claude").await.unwrap(),
|
||||
Some("a2".to_string()),
|
||||
"claude mis à jour"
|
||||
);
|
||||
assert_eq!(
|
||||
store.get(c, "codex").await.unwrap(),
|
||||
Some("b".to_string()),
|
||||
"codex préservé après re-set de claude"
|
||||
);
|
||||
|
||||
// Sur disque, le providers.json contient bien les deux clés (objet JSON plat).
|
||||
let raw = std::fs::read_to_string(tmp.providers_path(c)).unwrap();
|
||||
let map: std::collections::HashMap<String, String> = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(map.get("claude").map(String::as_str), Some("a2"));
|
||||
assert_eq!(map.get("codex").map(String::as_str), Some("b"));
|
||||
assert_eq!(map.len(), 2, "exactement les deux providers, got: {raw:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// absent ⇒ Ok(None) (conversation/provider jamais écrit ; dossier inexistant)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_get_absent_yields_none() {
|
||||
let tmp = TempDir::new();
|
||||
let store = FsProviderSessionStore::new(&tmp.project_path());
|
||||
|
||||
// Conversation jamais écrite : pas de dossier .ideai du tout.
|
||||
assert_eq!(
|
||||
store.get(conv_id(99), "claude").await.unwrap(),
|
||||
None,
|
||||
"conversation inexistante ⇒ None (pas d'erreur)"
|
||||
);
|
||||
|
||||
// Conversation avec un provider posé, mais provider demandé absent ⇒ None.
|
||||
let c = conv_id(98);
|
||||
store.set(c, "claude", "a").await.unwrap();
|
||||
assert_eq!(
|
||||
store.get(c, "codex").await.unwrap(),
|
||||
None,
|
||||
"clé provider absente ⇒ None"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// corrompu ⇒ Err(Serialization), aucun panic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_corrupted_file_yields_serialization_error_no_panic() {
|
||||
let tmp = TempDir::new();
|
||||
let store = FsProviderSessionStore::new(&tmp.project_path());
|
||||
|
||||
// Cas de contenus non-désérialisables en map<String,String>.
|
||||
let cases: [(u128, &str, &str); 3] = [
|
||||
(201, "{ ceci n'est pas du json", "JSON syntaxiquement invalide"),
|
||||
(202, "[\"pas\", \"un\", \"objet\"]", "JSON valide mais pas un objet map"),
|
||||
(203, "{\"claude\": 123}", "valeur non-String"),
|
||||
];
|
||||
|
||||
for (n, content, label) in cases {
|
||||
let c = conv_id(n);
|
||||
write_raw_providers(&tmp, c, content);
|
||||
|
||||
match store.get(c, "claude").await {
|
||||
Err(StoreError::Serialization(msg)) => {
|
||||
assert!(
|
||||
msg.starts_with("providers.json:"),
|
||||
"cas «{label}» : message préfixé `providers.json:` attendu, got: {msg:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("cas «{label}» : Err(Serialization) attendu, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// atomicité / concurrence : N set concurrents (providers distincts, même conv)
|
||||
// => la map finale contient les N entrées (aucune perte) ; pas de .tmp résiduel.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_concurrent_sets_keep_all_entries_no_tmp_left() {
|
||||
use std::sync::Arc;
|
||||
|
||||
let tmp = TempDir::new();
|
||||
let store = Arc::new(FsProviderSessionStore::new(&tmp.project_path()));
|
||||
let c = conv_id(42);
|
||||
|
||||
const N: usize = 12;
|
||||
let mut handles = Vec::new();
|
||||
for i in 0..N {
|
||||
let s = Arc::clone(&store);
|
||||
handles.push(tokio::spawn(async move {
|
||||
let provider = format!("provider-{i}");
|
||||
let id = format!("id-{i}");
|
||||
s.set(c, &provider, &id).await.unwrap();
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.await.unwrap();
|
||||
}
|
||||
|
||||
// Les N entrées sont toutes présentes : aucun set concurrent n'en a écrasé un autre.
|
||||
for i in 0..N {
|
||||
assert_eq!(
|
||||
store.get(c, &format!("provider-{i}")).await.unwrap(),
|
||||
Some(format!("id-{i}")),
|
||||
"provider-{i} perdu sous concurrence"
|
||||
);
|
||||
}
|
||||
|
||||
// Le fichier sur disque contient bien les N clés.
|
||||
let raw = std::fs::read_to_string(tmp.providers_path(c)).unwrap();
|
||||
let map: std::collections::HashMap<String, String> = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(map.len(), N, "map finale = N entrées, got: {raw:?}");
|
||||
|
||||
// Aucun fichier temporaire résiduel après les renames atomiques.
|
||||
assert!(
|
||||
!tmp.providers_tmp_path(c).exists(),
|
||||
"providers.json.tmp ne doit pas subsister après les set"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isolation entre deux ConversationId : pas de fuite croisée
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_is_isolated_per_conversation() {
|
||||
let tmp = TempDir::new();
|
||||
let store = FsProviderSessionStore::new(&tmp.project_path());
|
||||
let c1 = conv_id(10);
|
||||
let c2 = conv_id(20);
|
||||
|
||||
store.set(c1, "claude", "c1-id").await.unwrap();
|
||||
store.set(c2, "claude", "c2-id").await.unwrap();
|
||||
|
||||
// Chacune relit la sienne.
|
||||
assert_eq!(store.get(c1, "claude").await.unwrap(), Some("c1-id".to_string()));
|
||||
assert_eq!(store.get(c2, "claude").await.unwrap(), Some("c2-id".to_string()));
|
||||
|
||||
// Deux fichiers distincts sur disque, sans fuite de contenu.
|
||||
let p1 = tmp.providers_path(c1);
|
||||
let p2 = tmp.providers_path(c2);
|
||||
assert_ne!(p1, p2, "deux conversations ⇒ deux providers.json distincts");
|
||||
assert!(p1.exists() && p2.exists());
|
||||
let raw2 = std::fs::read_to_string(&p2).unwrap();
|
||||
assert!(!raw2.contains("c1-id"), "aucune fuite de c1 dans le providers.json de c2");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user