312 lines
12 KiB
Rust
312 lines
12 KiB
Rust
//! [`InMemoryConversationRegistry`] — the [`ConversationRegistry`] adapter (lot C2).
|
|
//!
|
|
//! The driven side of conversation-by-pair: a `HashMap<ConversationId, Conversation>`
|
|
//! plus a pair→id index, so `resolve` is **lazy get-or-create** and the same
|
|
//! unordered pair `{a, b}` always maps to the same [`ConversationId`].
|
|
//!
|
|
//! ## Concurrency
|
|
//!
|
|
//! A single **synchronous** [`Mutex`] guards both maps; it is held only for the O(1)
|
|
//! lookups/mutations and **never across an `.await`** (this registry is fully sync,
|
|
//! cf. the `ask_locks` discipline of `service.rs`).
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Mutex;
|
|
|
|
use domain::conversation::{
|
|
Conversation, ConversationId, ConversationParty, ConversationRegistry, ConversationSession,
|
|
SessionRef,
|
|
};
|
|
use domain::ids::ProjectId;
|
|
|
|
/// Order-insensitive key for a conversation pair `{a, b}`.
|
|
///
|
|
/// Normalised so that `{a, b}` and `{b, a}` hash/compare equal — this is what makes
|
|
/// `resolve(a, b)` and `resolve(b, a)` resolve to the same conversation.
|
|
fn pair_key(a: ConversationParty, b: ConversationParty) -> (ConversationParty, ConversationParty) {
|
|
// Stable, total ordering over parties so the smaller end is always `left` in the
|
|
// key. `User` sorts before any agent; agents order by their UUID.
|
|
fn rank(p: ConversationParty) -> (u8, u128) {
|
|
match p {
|
|
ConversationParty::User => (0, 0),
|
|
ConversationParty::Agent { agent_id } => (1, agent_id.as_uuid().as_u128()),
|
|
}
|
|
}
|
|
if rank(a) <= rank(b) {
|
|
(a, b)
|
|
} else {
|
|
(b, a)
|
|
}
|
|
}
|
|
|
|
/// In-memory, pair-keyed conversation registry (the production [`ConversationRegistry`]).
|
|
#[derive(Default)]
|
|
pub struct InMemoryConversationRegistry {
|
|
inner: Mutex<Inner>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Inner {
|
|
by_id: HashMap<ConversationId, Conversation>,
|
|
by_pair: HashMap<(ProjectId, ConversationParty, ConversationParty), ConversationId>,
|
|
}
|
|
|
|
impl InMemoryConversationRegistry {
|
|
/// Creates an empty registry.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
inner: Mutex::new(Inner::default()),
|
|
}
|
|
}
|
|
|
|
/// Number of conversations currently held (test/inspection helper).
|
|
#[must_use]
|
|
pub fn len(&self) -> usize {
|
|
self.lock().by_id.len()
|
|
}
|
|
|
|
/// Whether the registry is empty.
|
|
#[must_use]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.lock().by_id.is_empty()
|
|
}
|
|
|
|
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
|
|
self.inner
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
}
|
|
|
|
impl ConversationRegistry for InMemoryConversationRegistry {
|
|
fn resolve(
|
|
&self,
|
|
project_id: ProjectId,
|
|
a: ConversationParty,
|
|
b: ConversationParty,
|
|
) -> Conversation {
|
|
let key = pair_key(a, b);
|
|
let scoped_key = (project_id, key.0, key.1);
|
|
let mut inner = self.lock();
|
|
if let Some(id) = inner.by_pair.get(&scoped_key).copied() {
|
|
// Existing thread for this pair — return its current snapshot.
|
|
return inner
|
|
.by_id
|
|
.get(&id)
|
|
.cloned()
|
|
.expect("by_pair id always present in by_id");
|
|
}
|
|
// Lazy create: mint a Dormant conversation for the pair. The id is **derived
|
|
// deterministically** from the pair (`ConversationId::for_pair`, ARCHITECTURE
|
|
// §19.7) so the **same pair always yields the same id across registry instances**
|
|
// (i.e. it survives an IDE restart with an empty `by_pair`/`by_id`). This keeps
|
|
// the persistence key stable and aligned with `LaunchAgent` (P8a) / the
|
|
// `resolve_conversation` fallback. The pair is valid by construction at call
|
|
// sites; `try_new` still guards the invariants.
|
|
let id = ConversationId::for_project_pair(project_id, key.0, key.1);
|
|
let conv = Conversation::try_new(id, key.0, key.1)
|
|
.expect("pair_key yields a valid distinct/≤1-user pair");
|
|
inner.by_pair.insert(scoped_key, id);
|
|
inner.by_id.insert(id, conv.clone());
|
|
conv
|
|
}
|
|
|
|
fn bind_session(&self, id: ConversationId, session: SessionRef) {
|
|
let mut inner = self.lock();
|
|
if let Some(conv) = inner.by_id.get_mut(&id) {
|
|
conv.session = ConversationSession::Live {
|
|
handle_ref: session,
|
|
};
|
|
}
|
|
}
|
|
|
|
fn suspend(&self, id: ConversationId, resumable_id: Option<String>) {
|
|
let mut inner = self.lock();
|
|
if let Some(conv) = inner.by_id.get_mut(&id) {
|
|
conv.session = ConversationSession::Dormant;
|
|
conv.resumable_id = resumable_id;
|
|
}
|
|
}
|
|
|
|
fn get(&self, id: ConversationId) -> Option<Conversation> {
|
|
self.lock().by_id.get(&id).cloned()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use domain::ids::{AgentId, SessionId};
|
|
|
|
fn agent(n: u128) -> ConversationParty {
|
|
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
|
|
}
|
|
|
|
fn project(n: u128) -> ProjectId {
|
|
ProjectId::from_uuid(uuid::Uuid::from_u128(n))
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_is_lazy_get_or_create() {
|
|
let reg = InMemoryConversationRegistry::new();
|
|
assert!(reg.is_empty());
|
|
let c = reg.resolve(project(1), ConversationParty::User, agent(1));
|
|
assert_eq!(reg.len(), 1);
|
|
// Same pair ⇒ same id, no new conversation created.
|
|
let c2 = reg.resolve(project(1), ConversationParty::User, agent(1));
|
|
assert_eq!(c.id, c2.id);
|
|
assert_eq!(reg.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn same_pair_unordered_yields_same_id() {
|
|
let reg = InMemoryConversationRegistry::new();
|
|
let c1 = reg.resolve(project(1), agent(1), agent(2));
|
|
let c2 = reg.resolve(project(1), agent(2), agent(1)); // swapped order
|
|
assert_eq!(c1.id, c2.id, "unordered pair identity");
|
|
assert_eq!(reg.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn distinct_pairs_get_distinct_ids() {
|
|
let reg = InMemoryConversationRegistry::new();
|
|
let user_b = reg.resolve(project(1), ConversationParty::User, agent(2));
|
|
let a_b = reg.resolve(project(1), agent(1), agent(2));
|
|
assert_ne!(user_b.id, a_b.id, "User↔B and A↔B are different threads");
|
|
assert_eq!(reg.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn fresh_resolve_is_dormant() {
|
|
let reg = InMemoryConversationRegistry::new();
|
|
let c = reg.resolve(project(1), ConversationParty::User, agent(1));
|
|
assert_eq!(c.session, ConversationSession::Dormant);
|
|
}
|
|
|
|
#[test]
|
|
fn bind_session_makes_it_live_then_suspend_restores_dormant() {
|
|
let reg = InMemoryConversationRegistry::new();
|
|
let c = reg.resolve(project(1), ConversationParty::User, agent(1));
|
|
let sref = SessionRef::new(SessionId::from_uuid(uuid::Uuid::from_u128(99)));
|
|
reg.bind_session(c.id, sref);
|
|
let live = reg.get(c.id).unwrap();
|
|
assert!(live.session.is_live());
|
|
assert_eq!(live.session, ConversationSession::Live { handle_ref: sref });
|
|
|
|
reg.suspend(c.id, Some("sess-abc".to_owned()));
|
|
let dormant = reg.get(c.id).unwrap();
|
|
assert_eq!(dormant.session, ConversationSession::Dormant);
|
|
assert_eq!(dormant.resumable_id.as_deref(), Some("sess-abc"));
|
|
}
|
|
|
|
#[test]
|
|
fn get_unknown_is_none() {
|
|
let reg = InMemoryConversationRegistry::new();
|
|
assert!(reg.get(ConversationId::new_random()).is_none());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bloc 1 — déterminisme du registre (§19 : la clé de persistance = id de paire,
|
|
// déterministe et **stable au redémarrage**). Scelle l'invariant que Main a
|
|
// rattrapé : deux instances neuves du registre (= deux démarrages de l'IDE)
|
|
// doivent dériver **le même** id pour la même paire, et cet id doit être
|
|
// **exactement** `ConversationId::for_pair`, sinon la clé sous laquelle le
|
|
// handoff/log est rangé dériverait au redémarrage.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn resolve_is_stable_across_registry_restart() {
|
|
// Deux instances neuves (by_pair/by_id vides) simulent deux démarrages de
|
|
// l'IDE : la même paire User↔Agent doit produire le **même** id.
|
|
let a = agent(7);
|
|
let first = InMemoryConversationRegistry::new()
|
|
.resolve(project(1), ConversationParty::User, a)
|
|
.id;
|
|
let second = InMemoryConversationRegistry::new()
|
|
.resolve(project(1), ConversationParty::User, a)
|
|
.id;
|
|
assert_eq!(
|
|
first, second,
|
|
"id de paire stable au redémarrage (registre neuf ⇒ même id)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_is_stable_across_registry_restart_agent_agent() {
|
|
// Même garantie pour une paire Agent↔Agent (dérivation XOR commutative).
|
|
let x = agent(11);
|
|
let y = agent(13);
|
|
let first = InMemoryConversationRegistry::new()
|
|
.resolve(project(1), x, y)
|
|
.id;
|
|
let second = InMemoryConversationRegistry::new()
|
|
.resolve(project(1), y, x)
|
|
.id;
|
|
assert_eq!(
|
|
first, second,
|
|
"id de paire Agent↔Agent stable au redémarrage, insensible à l'ordre"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_id_equals_for_pair_user_agent() {
|
|
// Alignement de clé : l'id que le registre matérialise == le repli pur
|
|
// `for_pair` == `from_uuid(agent)` (la clé que P8a/`resolve_conversation`
|
|
// dérivent pour la paire canonique User↔Agent).
|
|
let agent_id = AgentId::from_uuid(uuid::Uuid::from_u128(42));
|
|
let party = ConversationParty::agent(agent_id);
|
|
let resolved = InMemoryConversationRegistry::new()
|
|
.resolve(project(1), ConversationParty::User, party)
|
|
.id;
|
|
assert_eq!(
|
|
resolved,
|
|
ConversationId::for_project_pair(project(1), ConversationParty::User, party),
|
|
"resolve == for_pair (alignement de clé)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_id_equals_for_pair_agent_agent_commutative() {
|
|
// Commutativité et alignement sur `for_pair` pour Agent↔Agent.
|
|
let x = agent(101);
|
|
let y = agent(202);
|
|
let reg = InMemoryConversationRegistry::new();
|
|
let id_xy = reg.resolve(project(1), x, y).id;
|
|
let id_yx = reg.resolve(project(1), y, x).id;
|
|
assert_eq!(id_xy, id_yx, "resolve(a,b) == resolve(b,a)");
|
|
assert_eq!(
|
|
id_xy,
|
|
ConversationId::for_project_pair(project(1), x, y),
|
|
"resolve == for_pair (Agent↔Agent)"
|
|
);
|
|
assert_eq!(reg.len(), 1, "une seule conversation pour la paire {{a,b}}");
|
|
}
|
|
|
|
#[test]
|
|
fn distinct_pairs_yield_distinct_ids_across_kinds() {
|
|
// Deux paires distinctes ⇒ deux ids distincts (pas de collision de clé).
|
|
let reg = InMemoryConversationRegistry::new();
|
|
let user_a = reg
|
|
.resolve(project(1), ConversationParty::User, agent(1))
|
|
.id;
|
|
let user_b = reg
|
|
.resolve(project(1), ConversationParty::User, agent(2))
|
|
.id;
|
|
let a_b = reg.resolve(project(1), agent(1), agent(2)).id;
|
|
assert_ne!(user_a, user_b, "User↔A ≠ User↔B");
|
|
assert_ne!(user_a, a_b, "User↔A ≠ A↔B");
|
|
assert_ne!(user_b, a_b, "User↔B ≠ A↔B");
|
|
}
|
|
|
|
#[test]
|
|
fn same_agent_ids_in_distinct_projects_get_distinct_threads() {
|
|
let reg = InMemoryConversationRegistry::new();
|
|
let p1 = reg.resolve(project(1), ConversationParty::User, agent(7));
|
|
let p2 = reg.resolve(project(2), ConversationParty::User, agent(7));
|
|
|
|
assert_ne!(p1.id, p2.id);
|
|
assert_eq!(reg.len(), 2);
|
|
}
|
|
}
|