Lot 6 : plusieurs discussions côté Rust (chat::sessions)

Port des opérations de chat_sessions.go et de LoadConversation :
discussion active créée au besoin (reprise du fil unique d'avant les
discussions, clé d'origine gardée), chargement au démarrage qui réessaie et
n'écrit rien sur une base illisible, liste du projet actif, création,
bascule (la courante enregistrée sous SON identifiant), renommage,
suppression (l'écriture en vol ne la fait pas renaître ; bascule sur la plus
récente restante ou une neuve : toujours une discussion active).

Les opérations sont sérialisées ; l'index partage son verrou avec
l'écrivain (StoreSink::index_lock, convIndexMu en Go). Fichiers, travaux,
critères, mode, résultats d'outils d'une discussion supprimée et
préchauffage après bascule passent par SessionHooks. Store gagne
get_open / put_seal (getStoreBytesErr / putStoreBytes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 10:09:00 +00:00
1 parent 74c2d5105c
commit a73b20ea04
5 files changed
+574 -7

No files matched your search

+2
View File
@@ -11,6 +11,7 @@
//! - [`conv`] : la discussion en mémoire (état, epoch, verrou du tour,
//! abonnés) ;
//! - [`persist`] : format en base, index des discussions, écrivain unique ;
//! - [`sessions`] : plusieurs discussions (index, bascules, chargement) ;
//! - [`lease`] : bail du moteur (verrou de génération partagé avec le Go).
pub mod compact;
@@ -19,6 +20,7 @@ pub mod engine;
pub mod journal;
pub mod lease;
pub mod persist;
pub mod sessions;
pub mod stream;
pub mod types;
pub mod wire;
+18 -4
View File
@@ -97,7 +97,7 @@ pub fn tool_res_conv(key: &str) -> &str {
}
}
fn now_unix() -> i64 {
pub(crate) fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
@@ -783,16 +783,30 @@ async fn run<S: PersistSink>(sh: Arc<Shared<S>>) {
#[derive(Clone)]
pub struct StoreSink {
pub store: Store,
/// Sérialise les lectures-modifications-écritures de l'index
/// (`convIndexMu`) : celles de l'écrivain et celles des opérations sur
/// les discussions ([`super::sessions`]), qui partagent ce verrou.
pub index_lock: Arc<tokio::sync::Mutex<()>>,
}
impl StoreSink {
pub fn new(store: Store) -> Self {
StoreSink {
store,
index_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
}
impl PersistSink for StoreSink {
fn commit(
async fn commit(
&self,
snaps: Vec<ConvSnap>,
res: Vec<ToolResJob>,
gate: ResGate,
) -> impl Future<Output = CommitOutcome> + Send {
commit_batch(&self.store, snaps, res, gate, now_unix())
) -> CommitOutcome {
let _index = self.index_lock.lock().await;
commit_batch(&self.store, snaps, res, gate, now_unix()).await
}
}
+352
View File
@@ -0,0 +1,352 @@
//! Plusieurs discussions — port des opérations de `chat_sessions.go` et du
//! chargement au démarrage (`LoadConversation`, `chat_conversation.go`).
//!
//! Une seule discussion en mémoire ([`Conversation`]), rangée dans un tiroir
//! nommé (`chat/conv:<id>`), listée dans `chat/index`, ouverte selon
//! `chat/active` — partagée par tous les appareils. Basculer = enregistrer la
//! courante, charger l'autre et incrémenter l'epoch : les abonnés reçoivent
//! `{reset}` et rejouent le nouveau fil.
//!
//! Les opérations de haut niveau (création, bascule, renommage, suppression)
//! sont sérialisées (`convOpMu`) ; les lectures-modifications-écritures de
//! l'index partagent leur verrou avec l'écrivain ([`StoreSink::index_lock`],
//! `convIndexMu`).
//!
//! Ce qui vit hors de la conversation reste derrière [`SessionHooks`] : les
//! fichiers, travaux, critères et mode d'une discussion supprimée, ses
//! résultats d'outils, et le préchauffage après une bascule. Le changement de
//! projet (`projectSwitch`) reste au Go pour l'instant.
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use super::conv::Conversation;
use super::persist::{
BK_CHAT, CK_ACTIVE, CK_INDEX, CK_LEGACY, ConvMeta, DEFAULT_PROJECT_SLUG, IndexError, Persister,
StoreSink, conv_key, decode_index, encode_index, index_for_project, new_conv_id, now_unix,
remove_from, rename_in,
};
use crate::store::{BK_STATE, Store};
/// Projet actif (`stActiveProjectKey`, bucket `state`).
pub const ST_ACTIVE_PROJECT: &str = "active_project";
/// Essais de lecture au démarrage, et attente entre deux (`loadConvAttempts`,
/// `loadConvRetryWait`) : l'ancien process peut encore tenir la base.
pub const LOAD_ATTEMPTS: u32 = 4;
pub const LOAD_RETRY_WAIT: Duration = Duration::from_millis(250);
/// Ce qui, autour d'une discussion, ne vit pas dans la conversation.
pub trait SessionHooks: Send + Sync + 'static {
/// Discussion supprimée : ses fichiers, travaux d'arrière-plan, critères,
/// mode et résultats d'outils partent avec elle.
fn dropped(&self, id: &str) -> impl Future<Output = ()> + Send;
/// Bascule faite (`prewarmAfterSwitch`).
fn switched(&self, id: &str) -> impl Future<Output = ()> + Send;
}
/// Refus d'une opération sur les discussions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionError {
/// « identifiant manquant » / « identifiant ou titre manquant »
Missing,
/// « discussion introuvable »
NotFound,
/// Base illisible.
Store(String),
}
impl std::fmt::Display for SessionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SessionError::Missing => f.write_str("identifiant ou titre manquant"),
SessionError::NotFound => f.write_str("discussion introuvable"),
SessionError::Store(e) => write!(f, "base illisible : {e}"),
}
}
}
impl From<IndexError> for SessionError {
fn from(e: IndexError) -> Self {
match e {
IndexError::Missing => SessionError::Missing,
IndexError::NotFound => SessionError::NotFound,
}
}
}
/// Les discussions de l'utilisateur, autour de la discussion ouverte.
pub struct Sessions<H: SessionHooks> {
store: Store,
conv: Arc<Conversation>,
persister: Persister<StoreSink>,
hooks: H,
/// `convOpMu`.
op: tokio::sync::Mutex<()>,
}
impl<H: SessionHooks> Sessions<H> {
pub fn new(store: Store, conv: Arc<Conversation>, hooks: H) -> Self {
let persister = Persister::new(StoreSink::new(store.clone()));
Sessions {
store,
conv,
persister,
hooks,
op: tokio::sync::Mutex::new(()),
}
}
pub fn conv(&self) -> &Arc<Conversation> {
&self.conv
}
pub fn persister(&self) -> &Persister<StoreSink> {
&self.persister
}
/// `activeProjectSlug` : projet actif, le projet par défaut à défaut.
pub async fn active_project(&self) -> String {
match self.store.get_str(BK_STATE, ST_ACTIVE_PROJECT).await.trim() {
"" => DEFAULT_PROJECT_SLUG.to_string(),
s => s.to_string(),
}
}
/// `convIndex` : l'index, plus récente en tête. Illisible ou verrouillé :
/// vide — la liste est masquée, rien n'est perdu.
pub async fn read_index(&self) -> Vec<ConvMeta> {
match self.store.get_open(BK_CHAT, CK_INDEX).await {
Ok(Some(b)) => decode_index(&b),
_ => Vec::new(),
}
}
async fn save_index(&self, idx: &[ConvMeta]) {
if let Err(e) = self
.store
.put_seal(BK_CHAT, CK_INDEX, encode_index(idx))
.await
{
eprintln!("[conv] index non enregistré : {e:?}");
}
}
/// Ajoute une entrée à l'index (création, migration).
async fn index_add(&self, id: &str) {
let now = now_unix();
let project = self.active_project().await;
let _g = self.persister.sink().index_lock.lock().await;
let mut idx = self.read_index().await;
idx.push(ConvMeta {
id: id.to_string(),
title: String::new(),
created: now,
updated: now,
turns: 0,
project,
});
self.save_index(&idx).await;
}
/// `LoadConversation` : recharge la discussion active au démarrage. Une
/// lecture RATÉE n'est pas une absence : on réessaie, et en cas d'échec
/// durable on ne touche à rien (ni identifiant forgé, ni fil écrasé).
pub async fn load(&self) -> Result<(), String> {
let _op = self.op.lock().await;
let mut last_err = String::new();
for attempt in 0..LOAD_ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(LOAD_RETRY_WAIT).await;
}
// Sonde : tant qu'elle échoue, surtout ne pas créer de discussion.
if let Err(e) = self.store.get(BK_CHAT, CK_ACTIVE).await {
last_err = e;
continue;
}
let id = self.ensure_active_locked().await?;
match self.store.get_open(BK_CHAT, &conv_key(&id)).await {
Ok(body) => {
self.conv.load_from(&id, &body.unwrap_or_default());
return Ok(());
}
Err(e) => last_err = e,
}
}
eprintln!(
"[conv] base illisible au démarrage ({last_err}) — aucune discussion chargée ; rien n'a été écrasé, l'historique est intact"
);
Err(last_err)
}
/// `convEnsureActive` : identifiant de la discussion active, créée au
/// besoin (reprise silencieuse du fil unique d'avant les discussions).
pub async fn ensure_active(&self) -> Result<String, String> {
let _op = self.op.lock().await;
self.ensure_active_locked().await
}
async fn ensure_active_locked(&self) -> Result<String, String> {
let cached = self.conv.id();
if !cached.is_empty() {
return Ok(cached);
}
if let Some(raw) = self.store.get(BK_CHAT, CK_ACTIVE).await? {
let id = String::from_utf8_lossy(&raw).trim().to_string();
if !id.is_empty() {
return Ok(id);
}
}
let id = new_conv_id();
if let Some(legacy) = self.store.get(BK_CHAT, CK_LEGACY).await?
&& !legacy.is_empty()
{
// Le fil unique devient la première discussion ; la clé d'origine
// reste intacte (retour arrière sans perte).
if let Err(e) = self.store.put_seal(BK_CHAT, &conv_key(&id), legacy).await {
eprintln!("[conv] reprise du fil unique non écrite : {e:?}");
}
}
self.store
.put(BK_CHAT, CK_ACTIVE, id.as_bytes().to_vec())
.await?;
self.index_add(&id).await;
Ok(id)
}
/// `ConvList` : les discussions du projet actif, et l'active.
pub async fn list(&self) -> Result<(Vec<ConvMeta>, String), String> {
let project = self.active_project().await;
let idx = self.read_index().await;
let list = index_for_project(&idx, &project)
.into_iter()
.cloned()
.collect();
Ok((list, self.ensure_active().await?))
}
/// `convActivate` : pointeur en base, identifiant et contenu changent
/// ensemble.
async fn activate(&self, id: &str) -> Result<(), SessionError> {
self.store
.put(BK_CHAT, CK_ACTIVE, id.as_bytes().to_vec())
.await
.map_err(SessionError::Store)?;
let body = self
.store
.get_open(BK_CHAT, &conv_key(id))
.await
.ok()
.flatten()
.unwrap_or_default();
self.conv.load_from(id, &body);
Ok(())
}
/// `persist` : enregistre la discussion ouverte et ATTEND l'écriture.
pub async fn persist(&self) {
let project = self.active_project().await;
let snap = self.conv.snapshot(&self.persister, &project);
if snap.id.is_empty() {
self.persister.flush().await;
return;
}
self.persister.persist(snap).await;
}
/// `persistAsync` : instantané tout de suite, écriture par l'écrivain.
pub async fn persist_async(&self) {
let project = self.active_project().await;
let snap = self.conv.snapshot(&self.persister, &project);
if !snap.id.is_empty() {
self.persister.enqueue_snap(snap);
}
}
/// `convCreate` : discussion vide ouverte SANS enregistrer la courante.
async fn create_locked(&self) -> Result<String, SessionError> {
let id = new_conv_id();
self.index_add(&id).await;
self.activate(&id).await?;
Ok(id)
}
/// `convNew` : met la courante de côté puis en ouvre une neuve.
pub async fn new_conv(&self) -> Result<String, SessionError> {
let _op = self.op.lock().await;
self.persist().await;
self.create_locked().await
}
/// `convSwitch` : enregistre la courante, charge la cible.
pub async fn switch(&self, id: &str) -> Result<(), SessionError> {
let _op = self.op.lock().await;
if id.is_empty() {
return Err(SessionError::Missing);
}
if !self.read_index().await.iter().any(|m| m.id == id) {
return Err(SessionError::NotFound);
}
if id
== self
.ensure_active_locked()
.await
.map_err(SessionError::Store)?
{
return Ok(());
}
self.persist().await;
self.activate(id).await?;
self.hooks.switched(id).await;
Ok(())
}
/// `convRename`.
pub async fn rename(&self, id: &str, title: &str) -> Result<(), SessionError> {
let _op = self.op.lock().await;
let _g = self.persister.sink().index_lock.lock().await;
let mut idx = self.read_index().await;
rename_in(&mut idx, id, title)?;
self.save_index(&idx).await;
Ok(())
}
/// `convDelete` : supprimer celle qui est ouverte bascule sur la plus
/// récente restante, ou sur une neuve : il y a TOUJOURS une discussion
/// active.
pub async fn delete(&self, id: &str) -> Result<(), SessionError> {
let _op = self.op.lock().await;
if !self.read_index().await.iter().any(|m| m.id == id) {
return Err(SessionError::NotFound);
}
// Rien ne doit plus la viser, puis l'écriture en vol est attendue :
// sinon elle la ferait renaître, contenu et entrée d'index.
self.persister.forget(id);
self.persister.flush().await;
let next = {
let _g = self.persister.sink().index_lock.lock().await;
let mut idx = self.read_index().await;
remove_from(&mut idx, id);
self.save_index(&idx).await;
idx
};
if let Err(e) = self.store.del(BK_CHAT, &conv_key(id)).await {
eprintln!("[conv] contenu de {id} non effacé : {e}");
}
self.hooks.dropped(id).await;
if id
!= self
.ensure_active_locked()
.await
.map_err(SessionError::Store)?
{
return Ok(());
}
match next.first() {
// Surtout pas new_conv : il réenregistrerait la supprimée.
None => self.create_locked().await.map(|_| ()),
Some(m) => self.activate(&m.id.clone()).await,
}
}
}
+25
View File
@@ -127,6 +127,31 @@ impl Store {
}
}
/// `getStoreBytesErr` : lit et ouvre une valeur scellée. Absente, ou
/// illisible (mémoire verrouillée, autre clé) → `Ok(None)` ; `Err`
/// seulement si la base n'a pas pu être lue (« rien » et « pas pu lire »
/// ne veulent pas dire la même chose).
pub async fn get_open(&self, bucket: &str, key: &str) -> Result<Option<Vec<u8>>, String> {
match self.get(bucket, key).await? {
Some(raw) if !raw.is_empty() => Ok(self.open(raw).await.ok()),
_ => Ok(None),
}
}
/// `putStoreBytes` : scelle puis écrit. Mémoire verrouillée : rien n'est
/// écrit (jamais en clair).
pub async fn put_seal(
&self,
bucket: &str,
key: &str,
plain: Vec<u8>,
) -> Result<(), CryptError> {
let sealed = self.seal(plain).await?;
self.put(bucket, key, sealed)
.await
.map_err(CryptError::Other)
}
/// Exécute un appel d'outil par le Go (`/internal/v1/tool/exec`) : même
/// fonction que sa boucle de chat. `caps` : objet JSON des capacités du
/// tour (champs Go : Agent, Internet, Mem, Code, Role, ComputerUse).