mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 13:38:11 +02:00
Port d'EnqueueOrStart, StartTurn, generate, drainQueued, startQueuedIfAny, dropQueued et compactAndPublish : - envoi : réessai reconnu (cid), tour préparé par le Go (/turn/start), mis en file si un tour tourne, sinon moteur prêt (/health), verrou du tour puis bail du moteur du Go, bulle journalisée et enregistrée tout de suite ; - tour : compaction de début de tour, vue d'envoi (tête du Go, historique sans <context_update>, préambule fusionné comme InjectSkills), boucle dont chaque événement devient un delta du journal, historique rangé (outils, réponse, raisonnement renvoyé, rappels en porte-à-faux retirés), contexte estimé si le moteur ne le compte pas, compaction de fin de tour ; - fin : turn_done, journal coalescé, verrou et bail rendus, discussion enregistrée, file abandonnée après un stop ou message suivant lancé, sinon Web Push ; - stop : le tour en cours, ou le détenteur Go du moteur. chat::history porte historyFromView, dropStrayNudges, le rappel des pages mémoire lues, l'assemblage d'InjectSkills et la forme des tool_used du journal, avec leurs tests Go. ConvState garde ctxUsedLen et genPeak en mémoire seulement. EngineLease::interrupted ne garde plus de verrou à travers une attente. Écarts : boucle de vérification du mode Code, critères pour la compaction, préchauffage, télémétrie, retry_patterns personnalisés. Tests de bout en bout : tour complet (outil, réponse, bail rendu, discussion enregistrée, doublon), message en file injecté entre deux étapes, moteur tenu par une tâche planifiée (409, stop), moteur pas prêt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
1437 lines
48 KiB
Rust
1437 lines
48 KiB
Rust
//! Persistance des discussions — port de `chat_persist.go` et de la partie
|
|
//! « index » de `chat_sessions.go`.
|
|
//!
|
|
//! Même format en base que le Go, pour qu'une discussion écrite par l'un se
|
|
//! relise par l'autre (et pour la comparaison Go / Rust du lot 6) :
|
|
//!
|
|
//! ```text
|
|
//! chat/index → liste des discussions (ConvMeta, sans les messages), scellée
|
|
//! chat/active → identifiant de la discussion ouverte (en clair)
|
|
//! chat/conv:<id> → état complet d'une discussion (ConvState), scellé
|
|
//! toolres/<id>.<aléa> → résultat complet d'un outil (« voir plus »), scellé
|
|
//! ```
|
|
//!
|
|
//! Trois parties :
|
|
//! - le FORMAT : [`ConvState`] (JSON de `Conversation`), son instantané
|
|
//! [`take_snapshot`] et sa relecture tolérante [`decode_state`] ;
|
|
//! - l'INDEX : [`ConvMeta`], titres ([`conv_summary`]), tri, projets,
|
|
//! rafraîchissement d'une entrée ([`touch_meta_in`]) ;
|
|
//! - l'ÉCRIVAIN UNIQUE : [`Persister`], qui sort l'écriture du chemin du
|
|
//! premier jeton, fusionne ce qui s'accumule (dernier instantané de chaque
|
|
//! discussion + résultats d'outils) en un lot, n'écrit jamais un état plus
|
|
//! ancien qu'un état déjà écrit et ne fait jamais renaître une discussion
|
|
//! supprimée. Le lot part dans un [`PersistSink`] ; [`StoreSink`] l'écrit
|
|
//! par l'API interne du Go ([`crate::store::Store`]), scellé par lui.
|
|
//!
|
|
//! Écarts assumés avec le Go :
|
|
//! - une seule base : le chemin (`path`, `withDBAt`) disparaît des instantanés ;
|
|
//! - pas de transaction bbolt à travers l'API interne : [`StoreSink`] écrit les
|
|
//! résultats, puis chaque contenu, puis l'index. Un échec au milieu laisse
|
|
//! des contenus à jour sous un index en retard — le prochain instantané le
|
|
//! rattrape ([`touch_meta_in`] ajoute une entrée absente). Tant que la
|
|
//! conversation est tenue par Rust, le Go ne touche plus à l'index ;
|
|
//! - images en base64 → références (`refImagesInMessages`) : à faire par
|
|
//! l'appelant avant [`take_snapshot`], qui ne touche pas au disque ;
|
|
//! - relecture : un champ mal typé est ignoré seul (comme `json.Unmarshal`) ;
|
|
//! un message ou un événement mal typé est écarté entier (le Go le garderait
|
|
//! à moitié rempli).
|
|
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
use std::future::Future;
|
|
use std::panic::AssertUnwindSafe;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex, MutexGuard};
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
|
|
use futures_util::FutureExt;
|
|
use serde::de::DeserializeOwned;
|
|
use serde::{Deserialize, Serialize, Serializer};
|
|
use serde_json::Value;
|
|
use tokio::sync::watch;
|
|
|
|
use super::compact::{IMAGE_LOST_MARKER, is_loki_injected, without_ctx_update};
|
|
use super::journal::compact_log;
|
|
use super::types::{LogEvent, Message};
|
|
use crate::store::{CryptError, Store};
|
|
|
|
// ---- clés en base ----------------------------------------------------------
|
|
|
|
/// Bucket des discussions (`bkChat`).
|
|
pub const BK_CHAT: &str = "chat";
|
|
/// Bucket des résultats d'outils complets (`bkToolRes`).
|
|
pub const BK_TOOL_RES: &str = "toolres";
|
|
/// Liste des discussions (`ckIndex`).
|
|
pub const CK_INDEX: &str = "index";
|
|
/// Discussion ouverte (`ckActive`).
|
|
pub const CK_ACTIVE: &str = "active";
|
|
/// Fil unique d'avant le multi-discussions (`ckLegacy`).
|
|
pub const CK_LEGACY: &str = "conversation";
|
|
/// Projet d'une discussion qui n'en a pas (`defaultProjectSlug`).
|
|
pub const DEFAULT_PROJECT_SLUG: &str = "generale";
|
|
|
|
/// Délai d'attente de l'écrivain avant une sortie (`persistExitWait`).
|
|
pub const PERSIST_EXIT_WAIT: Duration = Duration::from_secs(6);
|
|
|
|
/// Longueur d'un titre déduit, en caractères (`convSummary`).
|
|
const TITLE_RUNES: usize = 60;
|
|
|
|
/// `convKey` : clé du contenu d'une discussion.
|
|
pub fn conv_key(id: &str) -> String {
|
|
format!("conv:{id}")
|
|
}
|
|
|
|
/// `newConvID` : horodatage en nanosecondes — monotone, lisible au tri.
|
|
pub fn new_conv_id() -> String {
|
|
let ns = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
format!("c{ns}")
|
|
}
|
|
|
|
/// `toolResConv` : identifiant de discussion d'une clé « <id>.<aléa> ».
|
|
pub fn tool_res_conv(key: &str) -> &str {
|
|
match key.rfind('.') {
|
|
Some(i) if i > 0 => &key[..i],
|
|
_ => key,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn now_unix() -> i64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
// ---- format : état d'une discussion ----------------------------------------
|
|
|
|
/// Bloc projet figé d'une discussion (`projSnapshot`, PROJ_SNAPSHOT). Porté
|
|
/// tel quel : la façade ne le calcule pas encore, elle le garde intact.
|
|
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
|
pub struct ProjSnapshot {
|
|
#[serde(default)]
|
|
pub at: i64,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub msgs: Vec<Message>,
|
|
#[serde(default)]
|
|
pub state: ProjState,
|
|
#[serde(default)]
|
|
pub key: String,
|
|
#[serde(default)]
|
|
pub sys_hash: String,
|
|
#[serde(default, skip_serializing_if = "is_zero")]
|
|
pub snap_tok: i64,
|
|
#[serde(default, skip_serializing_if = "is_zero")]
|
|
pub delta_tok: i64,
|
|
}
|
|
|
|
/// Dernier état du projet annoncé au modèle (`projState`). Les tables sont
|
|
/// triées par clé, comme une `map` Go marshalée.
|
|
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
|
pub struct ProjState {
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
pub day: String,
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
pub env: String,
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
pub code: String,
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
pub desc: String,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub mem_on: bool,
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
pub mem_prose: String,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub mem: BTreeMap<String, String>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub trk: BTreeMap<String, String>,
|
|
}
|
|
|
|
fn is_zero(v: &i64) -> bool {
|
|
*v == 0
|
|
}
|
|
fn is_false(b: &bool) -> bool {
|
|
!*b
|
|
}
|
|
|
|
/// Ce qui est persisté d'une `Conversation` : la vue du modèle, le journal
|
|
/// d'affichage, le compteur de séquence, le contexte mesuré et le bloc projet.
|
|
/// Le reste (génération en cours, epoch, file d'attente…) vit en mémoire.
|
|
#[derive(Debug, Clone, PartialEq, Default)]
|
|
pub struct ConvState {
|
|
pub messages: Vec<Message>,
|
|
pub log: Vec<LogEvent>,
|
|
pub seq: i64,
|
|
pub ctx_used: i64,
|
|
pub proj_snap: Option<ProjSnapshot>,
|
|
/// Messages que `ctx_used` couvre déjà (0 = inconnu) — en mémoire
|
|
/// seulement : un rechargement repart du seul `ctx_used`.
|
|
pub ctx_used_len: usize,
|
|
/// Génération la plus longue du dernier tour (moteur local), pour la garde
|
|
/// de marge de la compaction — en mémoire seulement.
|
|
pub gen_peak: i64,
|
|
}
|
|
|
|
/// Forme écrite, empruntée : l'instantané en plein tour écrit un journal
|
|
/// compacté sans toucher à celui que suivent les abonnés.
|
|
#[derive(Serialize)]
|
|
struct StateOut<'a> {
|
|
#[serde(serialize_with = "nil_if_empty")]
|
|
messages: &'a [Message],
|
|
#[serde(serialize_with = "nil_if_empty")]
|
|
log: &'a [LogEvent],
|
|
seq: i64,
|
|
ctx_used: i64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
proj_snap: Option<&'a ProjSnapshot>,
|
|
}
|
|
|
|
/// Une tranche Go vide (nil) s'écrit `null` : même octets que le Go.
|
|
fn nil_if_empty<T: Serialize, S: Serializer>(v: &&[T], s: S) -> Result<S::Ok, S::Error> {
|
|
if v.is_empty() {
|
|
s.serialize_none()
|
|
} else {
|
|
v.serialize(s)
|
|
}
|
|
}
|
|
|
|
impl ConvState {
|
|
fn out<'a>(&'a self, log: &'a [LogEvent]) -> StateOut<'a> {
|
|
StateOut {
|
|
messages: &self.messages,
|
|
log,
|
|
seq: self.seq,
|
|
ctx_used: self.ctx_used,
|
|
proj_snap: self.proj_snap.as_ref(),
|
|
}
|
|
}
|
|
|
|
/// JSON de la discussion, tel que `json.Marshal(conv)`.
|
|
pub fn to_json(&self) -> Vec<u8> {
|
|
serde_json::to_vec(&self.out(&self.log)).unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
impl Serialize for ConvState {
|
|
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
|
self.out(&self.log).serialize(s)
|
|
}
|
|
}
|
|
|
|
/// Relit une discussion persistée — `json.Unmarshal` dans une `Conversation`
|
|
/// remise à zéro (`loadFrom`), puis la guérison `stripImageParts`. Contenu
|
|
/// vide ou illisible → fil neuf ; un champ mal typé est ignoré seul.
|
|
pub fn decode_state(b: &[u8]) -> ConvState {
|
|
let mut st = ConvState::default();
|
|
let Ok(Value::Object(mut obj)) = serde_json::from_slice::<Value>(b) else {
|
|
return st;
|
|
};
|
|
if let Some(v) = obj.remove("messages") {
|
|
st.messages = decode_list(v);
|
|
}
|
|
if let Some(v) = obj.remove("log") {
|
|
st.log = decode_list(v);
|
|
}
|
|
if let Some(n) = obj
|
|
.remove("seq")
|
|
.and_then(|v| serde_json::from_value(v).ok())
|
|
{
|
|
st.seq = n;
|
|
}
|
|
if let Some(n) = obj
|
|
.remove("ctx_used")
|
|
.and_then(|v| serde_json::from_value(v).ok())
|
|
{
|
|
st.ctx_used = n;
|
|
}
|
|
if let Some(p) = obj
|
|
.remove("proj_snap")
|
|
.and_then(|v| serde_json::from_value(v).ok())
|
|
{
|
|
st.proj_snap = p;
|
|
}
|
|
strip_image_parts(&mut st.messages);
|
|
st
|
|
}
|
|
|
|
/// Tableau JSON → éléments décodables ; `null` ou autre chose → vide.
|
|
fn decode_list<T: DeserializeOwned>(v: Value) -> Vec<T> {
|
|
match v {
|
|
Value::Array(items) => items
|
|
.into_iter()
|
|
.filter_map(|x| serde_json::from_value(x).ok())
|
|
.collect(),
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// `stripImageParts` : un message multimodal persisté (base64 d'une capture
|
|
/// d'avant les captures éphémères) est aplati en son texte suivi de
|
|
/// [`IMAGE_LOST_MARKER`] — rejoué à chaque tour, il dépassait le contexte.
|
|
pub fn strip_image_parts(msgs: &mut [Message]) {
|
|
for m in msgs.iter_mut() {
|
|
let Some(Value::Array(parts)) = &m.content else {
|
|
continue;
|
|
};
|
|
let mut texts: Vec<&str> = Vec::new();
|
|
let mut dropped = false;
|
|
for p in parts.iter().filter_map(Value::as_object) {
|
|
if p.get("type").and_then(Value::as_str) == Some("image_url") {
|
|
dropped = true;
|
|
continue;
|
|
}
|
|
if let Some(Value::String(t)) = p.get("text") {
|
|
texts.push(t);
|
|
}
|
|
}
|
|
if dropped {
|
|
let text = texts.join("\n") + IMAGE_LOST_MARKER;
|
|
m.content = Some(Value::String(text));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Un instantané complet d'une discussion, prêt à écrire (`convSnap`).
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct ConvSnap {
|
|
/// Discussion à laquelle appartient le contenu.
|
|
pub id: String,
|
|
/// Ordre de prise : le plus grand l'emporte toujours.
|
|
pub seq: u64,
|
|
/// JSON de la discussion.
|
|
pub body: Vec<u8>,
|
|
/// Titre déduit du premier message ([`conv_summary`]).
|
|
pub title: String,
|
|
/// Échanges : événements `user` du journal.
|
|
pub turns: i64,
|
|
/// Projet d'une entrée d'index à créer.
|
|
pub project: String,
|
|
}
|
|
|
|
/// `snapshot` : instantané de `state` sous la discussion `id`. En plein tour
|
|
/// (`generating`), le journal porte encore un événement par jeton : on écrit
|
|
/// la forme qu'aura le journal en fin de tour (`compactLog`). `seq` vient de
|
|
/// [`Persister::next_seq`], pris sous le même verrou que l'état.
|
|
pub fn take_snapshot(
|
|
state: &ConvState,
|
|
generating: bool,
|
|
id: &str,
|
|
seq: u64,
|
|
project: &str,
|
|
) -> ConvSnap {
|
|
let body = if generating {
|
|
let compacted = compact_log(&state.log);
|
|
serde_json::to_vec(&state.out(&compacted)).unwrap_or_default()
|
|
} else {
|
|
state.to_json()
|
|
};
|
|
let turns = state
|
|
.log
|
|
.iter()
|
|
.filter(|ev| ev.delta.contains_key("user"))
|
|
.count() as i64;
|
|
ConvSnap {
|
|
id: id.to_string(),
|
|
seq,
|
|
body,
|
|
title: conv_summary(&state.messages),
|
|
turns,
|
|
project: project.to_string(),
|
|
}
|
|
}
|
|
|
|
// ---- index des discussions -------------------------------------------------
|
|
|
|
/// Une discussion SANS ses messages : ce que liste l'UI (`convMeta`).
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
pub struct ConvMeta {
|
|
#[serde(default)]
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub title: String,
|
|
#[serde(default)]
|
|
pub created: i64,
|
|
#[serde(default)]
|
|
pub updated: i64,
|
|
#[serde(default)]
|
|
pub turns: i64,
|
|
/// Slug du projet ; vide sur les discussions d'avant les projets.
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
pub project: String,
|
|
}
|
|
|
|
/// `convProjectOf` : projet d'une discussion, l'absence valant le projet par
|
|
/// défaut — une discussion sans projet ne doit jamais devenir invisible.
|
|
pub fn conv_project_of(m: &ConvMeta) -> &str {
|
|
match m.project.trim() {
|
|
"" => DEFAULT_PROJECT_SLUG,
|
|
s => s,
|
|
}
|
|
}
|
|
|
|
/// Index relu (`convIndex`) : JSON déjà ouvert ; illisible → vide. Plus
|
|
/// récemment modifiée en tête, tri stable.
|
|
pub fn decode_index(b: &[u8]) -> Vec<ConvMeta> {
|
|
let mut idx: Vec<ConvMeta> = serde_json::from_slice::<Option<Vec<ConvMeta>>>(b)
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or_default();
|
|
sort_index(&mut idx);
|
|
idx
|
|
}
|
|
|
|
/// Plus récemment modifiée en tête (tri stable, comme `sort.SliceStable`).
|
|
pub fn sort_index(idx: &mut [ConvMeta]) {
|
|
idx.sort_by_key(|m| std::cmp::Reverse(m.updated));
|
|
}
|
|
|
|
/// JSON de l'index, tel que `json.Marshal([]convMeta)` (vide → `null`).
|
|
pub fn encode_index(idx: &[ConvMeta]) -> Vec<u8> {
|
|
if idx.is_empty() {
|
|
return b"null".to_vec();
|
|
}
|
|
serde_json::to_vec(idx).unwrap_or_default()
|
|
}
|
|
|
|
/// `convIndexForProject` : les discussions d'un projet.
|
|
pub fn index_for_project<'a>(idx: &'a [ConvMeta], slug: &str) -> Vec<&'a ConvMeta> {
|
|
idx.iter().filter(|m| conv_project_of(m) == slug).collect()
|
|
}
|
|
|
|
/// `tagOrphanConversations` : rattache au projet les discussions qui n'en ont
|
|
/// pas. Rend vrai si l'index a changé (à réécrire).
|
|
pub fn tag_orphans(idx: &mut [ConvMeta], slug: &str) -> bool {
|
|
let mut changed = false;
|
|
for m in idx.iter_mut().filter(|m| m.project.trim().is_empty()) {
|
|
m.project = slug.to_string();
|
|
changed = true;
|
|
}
|
|
changed
|
|
}
|
|
|
|
/// Refus d'une opération sur l'index (messages du Go).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum IndexError {
|
|
/// « identifiant ou titre manquant »
|
|
Missing,
|
|
/// « discussion introuvable »
|
|
NotFound,
|
|
}
|
|
|
|
impl std::fmt::Display for IndexError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(match self {
|
|
IndexError::Missing => "identifiant ou titre manquant",
|
|
IndexError::NotFound => "discussion introuvable",
|
|
})
|
|
}
|
|
}
|
|
|
|
/// `convRename`, sur l'index : titre choisi par l'utilisateur (rogné).
|
|
pub fn rename_in(idx: &mut [ConvMeta], id: &str, title: &str) -> Result<(), IndexError> {
|
|
let title = title.trim();
|
|
if id.is_empty() || title.is_empty() {
|
|
return Err(IndexError::Missing);
|
|
}
|
|
let m = idx
|
|
.iter_mut()
|
|
.find(|m| m.id == id)
|
|
.ok_or(IndexError::NotFound)?;
|
|
m.title = title.to_string();
|
|
Ok(())
|
|
}
|
|
|
|
/// Retire une discussion de l'index (`convDelete`) ; faux si absente.
|
|
pub fn remove_from(idx: &mut Vec<ConvMeta>, id: &str) -> bool {
|
|
let n = idx.len();
|
|
idx.retain(|m| m.id != id);
|
|
idx.len() != n
|
|
}
|
|
|
|
/// `convTouchMetaIn` : rafraîchit l'entrée d'un instantané (date, échanges,
|
|
/// titre déduit si l'utilisateur n'en a pas choisi). Une discussion absente
|
|
/// est ajoutée — c'est ce qui recolle une discussion créée mémoire
|
|
/// verrouillée.
|
|
pub fn touch_meta_in(idx: &mut Vec<ConvMeta>, s: &ConvSnap, now: i64) {
|
|
if let Some(m) = idx.iter_mut().find(|m| m.id == s.id) {
|
|
m.updated = now;
|
|
m.turns = s.turns;
|
|
if m.title.is_empty() {
|
|
m.title = s.title.clone();
|
|
}
|
|
return;
|
|
}
|
|
idx.push(ConvMeta {
|
|
id: s.id.clone(),
|
|
title: s.title.clone(),
|
|
created: now,
|
|
updated: now,
|
|
turns: s.turns,
|
|
project: s.project.clone(),
|
|
});
|
|
}
|
|
|
|
/// `convSummary` : le premier message de l'utilisateur fait le titre (rappels
|
|
/// de loki et bloc <context_update> exclus), 60 caractères au plus. Vide sans
|
|
/// message : l'UI affiche « Nouvelle discussion ».
|
|
pub fn conv_summary(msgs: &[Message]) -> String {
|
|
for m in msgs {
|
|
if m.role != "user" || is_loki_injected(m) {
|
|
continue;
|
|
}
|
|
let stripped = without_ctx_update(m);
|
|
let m = stripped.as_ref().unwrap_or(m);
|
|
let Some(Value::String(s)) = &m.content else {
|
|
continue;
|
|
};
|
|
let s = s.replace('\n', " ");
|
|
let s = s.trim();
|
|
if s.is_empty() {
|
|
continue;
|
|
}
|
|
if s.chars().count() > TITLE_RUNES {
|
|
let head: String = s.chars().take(TITLE_RUNES).collect();
|
|
return format!("{}…", head.trim());
|
|
}
|
|
return s.to_string();
|
|
}
|
|
String::new()
|
|
}
|
|
|
|
// ---- écrivain unique -------------------------------------------------------
|
|
|
|
/// Un résultat d'outil complet à ranger dans [`BK_TOOL_RES`] (`toolResJob`).
|
|
#[derive(Debug, Clone)]
|
|
pub struct ToolResJob {
|
|
/// « <discussion>.<aléa> ».
|
|
pub key: String,
|
|
pub plain: Arc<str>,
|
|
}
|
|
|
|
/// Bilan d'un lot : instantanés écrits (discussion, seq) et première erreur.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct CommitOutcome {
|
|
pub wrote: Vec<(String, u64)>,
|
|
pub err: Option<String>,
|
|
}
|
|
|
|
/// Écrit un lot (`persistCommit`). Les résultats d'outils d'abord, chacun
|
|
/// sauté si [`ResGate::wanted`] dit qu'une suppression l'a annulé.
|
|
pub trait PersistSink: Send + Sync + 'static {
|
|
fn commit(
|
|
&self,
|
|
snaps: Vec<ConvSnap>,
|
|
res: Vec<ToolResJob>,
|
|
gate: ResGate,
|
|
) -> impl Future<Output = CommitOutcome> + Send;
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct PState {
|
|
/// Dernier instantané en attente, par discussion.
|
|
snaps: HashMap<String, ConvSnap>,
|
|
/// Résultats d'outils en attente, dans l'ordre.
|
|
res: Vec<ToolResJob>,
|
|
/// Résultats pas encore écrits : « voir plus » les sert d'ici.
|
|
mem: HashMap<String, Arc<str>>,
|
|
/// Seq du dernier instantané écrit, par discussion.
|
|
written: HashMap<String, u64>,
|
|
/// Discussions supprimées : plus rien ne doit les réécrire.
|
|
deleted: HashSet<String>,
|
|
queued: u64,
|
|
done: u64,
|
|
running: bool,
|
|
/// Résultats du lot en cours d'écriture, et ceux qu'une suppression a
|
|
/// annulés depuis.
|
|
inflight: HashSet<String>,
|
|
cancelled: HashSet<String>,
|
|
}
|
|
|
|
fn lock(m: &Mutex<PState>) -> MutexGuard<'_, PState> {
|
|
m.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// Ce que l'écrivain consulte DANS son lot (`toolResWanted`).
|
|
#[derive(Clone)]
|
|
pub struct ResGate(Arc<Mutex<PState>>);
|
|
|
|
impl ResGate {
|
|
/// Ce résultat du lot en cours doit-il encore être écrit ?
|
|
pub fn wanted(&self, key: &str) -> bool {
|
|
!lock(&self.0).cancelled.contains(key)
|
|
}
|
|
}
|
|
|
|
struct Shared<S> {
|
|
sink: S,
|
|
st: Arc<Mutex<PState>>,
|
|
done: watch::Sender<u64>,
|
|
seq: AtomicU64,
|
|
}
|
|
|
|
/// L'écrivain unique (`convPersister`). Un seul lot à la fois : l'ordre des
|
|
/// instantanés ne dépend que de leur seq. Les méthodes qui confient un
|
|
/// travail démarrent l'écrivain sur le runtime tokio courant.
|
|
pub struct Persister<S: PersistSink> {
|
|
sh: Arc<Shared<S>>,
|
|
}
|
|
|
|
impl<S: PersistSink> Clone for Persister<S> {
|
|
fn clone(&self) -> Self {
|
|
Persister {
|
|
sh: self.sh.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<S: PersistSink> Persister<S> {
|
|
pub fn new(sink: S) -> Self {
|
|
Persister {
|
|
sh: Arc::new(Shared {
|
|
sink,
|
|
st: Arc::new(Mutex::new(PState::default())),
|
|
done: watch::Sender::new(0),
|
|
seq: AtomicU64::new(0),
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn sink(&self) -> &S {
|
|
&self.sh.sink
|
|
}
|
|
|
|
/// Numéro du prochain instantané (`persistSeq`). À prendre sous le verrou
|
|
/// de l'état, pour que l'ordre des numéros soit celui des états.
|
|
pub fn next_seq(&self) -> u64 {
|
|
self.sh.seq.fetch_add(1, Ordering::SeqCst) + 1
|
|
}
|
|
|
|
/// Confie un instantané et rend son ticket. Un instantané en attente pour
|
|
/// la même discussion est remplacé par le plus récent.
|
|
pub fn enqueue_snap(&self, s: ConvSnap) -> u64 {
|
|
let mut st = lock(&self.sh.st);
|
|
if !st.deleted.contains(&s.id) {
|
|
let newer = st.snaps.get(&s.id).is_none_or(|cur| s.seq > cur.seq);
|
|
if newer {
|
|
st.snaps.insert(s.id.clone(), s);
|
|
}
|
|
}
|
|
self.kick(&mut st)
|
|
}
|
|
|
|
/// Confie un résultat d'outil ; il est servi depuis la mémoire tant qu'il
|
|
/// n'est pas sur disque.
|
|
pub fn enqueue_tool_res(&self, j: ToolResJob) -> u64 {
|
|
let mut st = lock(&self.sh.st);
|
|
if !st.deleted.contains(tool_res_conv(&j.key)) {
|
|
st.mem.insert(j.key.clone(), j.plain.clone());
|
|
st.res.push(j);
|
|
}
|
|
self.kick(&mut st)
|
|
}
|
|
|
|
fn kick(&self, st: &mut PState) -> u64 {
|
|
st.queued += 1;
|
|
if !st.running {
|
|
st.running = true;
|
|
tokio::spawn(run(self.sh.clone()));
|
|
}
|
|
st.queued
|
|
}
|
|
|
|
/// Attend que le ticket (et tous ceux d'avant) soit traité.
|
|
pub async fn wait(&self, ticket: u64) {
|
|
let mut rx = self.sh.done.subscribe();
|
|
let _ = rx.wait_for(|d| *d >= ticket).await;
|
|
}
|
|
|
|
/// Attend que tout ce qui a été confié jusqu'ici soit traité.
|
|
pub async fn flush(&self) {
|
|
let t = lock(&self.sh.st).queued;
|
|
self.wait(t).await;
|
|
}
|
|
|
|
/// `flushPersister` : [`Self::flush`] borné, pour ne jamais retenir un
|
|
/// arrêt sur une base bloquée. Faux si le délai a expiré.
|
|
pub async fn flush_timeout(&self, d: Duration) -> bool {
|
|
tokio::time::timeout(d, self.flush()).await.is_ok()
|
|
}
|
|
|
|
/// Prend l'instantané et ATTEND son écriture (`persist`) : fin de tour,
|
|
/// reset, bascules. Tout ce qui était en attente avant est écrit aussi.
|
|
pub async fn persist(&self, s: ConvSnap) {
|
|
let t = self.enqueue_snap(s);
|
|
self.wait(t).await;
|
|
}
|
|
|
|
/// Marque une discussion supprimée : ses instantanés et résultats en
|
|
/// attente sont jetés, et plus rien ne la réécrira. L'appelant attend
|
|
/// ensuite l'écriture en vol ([`Self::flush`]) avant d'effacer en base.
|
|
pub fn forget(&self, id: &str) {
|
|
let mut st = lock(&self.sh.st);
|
|
st.deleted.insert(id.to_string());
|
|
st.snaps.remove(id);
|
|
drop_tool_res_locked(&mut st, &format!("{id}."));
|
|
}
|
|
|
|
/// Oublie les résultats en attente dont la clé commence par `prefix`
|
|
/// (`dropToolRes`). Ceux du lot en vol sont annulés : l'écrivain les
|
|
/// saute, ou les a déjà écrits — l'appelant fait alors [`Self::flush`]
|
|
/// avant d'effacer, et rien ne renaît.
|
|
pub fn drop_tool_res(&self, prefix: &str) {
|
|
drop_tool_res_locked(&mut lock(&self.sh.st), prefix);
|
|
}
|
|
|
|
/// Relit un résultat pas encore sur disque (`toolResPending`).
|
|
pub fn tool_res_pending(&self, key: &str) -> Option<Arc<str>> {
|
|
lock(&self.sh.st).mem.get(key).cloned()
|
|
}
|
|
}
|
|
|
|
fn drop_tool_res_locked(st: &mut PState, prefix: &str) {
|
|
let hit: Vec<String> = st
|
|
.inflight
|
|
.iter()
|
|
.filter(|k| k.starts_with(prefix))
|
|
.cloned()
|
|
.collect();
|
|
st.cancelled.extend(hit);
|
|
st.mem.retain(|k, _| !k.starts_with(prefix));
|
|
st.res.retain(|j| !j.key.starts_with(prefix));
|
|
}
|
|
|
|
/// Vide la file par lots, jusqu'à ce qu'elle soit vide.
|
|
async fn run<S: PersistSink>(sh: Arc<Shared<S>>) {
|
|
loop {
|
|
let (upto, mut snaps, res) = {
|
|
let mut st = lock(&sh.st);
|
|
if st.snaps.is_empty() && st.res.is_empty() {
|
|
st.running = false;
|
|
st.done = st.queued;
|
|
sh.done.send_replace(st.done);
|
|
return;
|
|
}
|
|
let pending = std::mem::take(&mut st.snaps);
|
|
let snaps: Vec<ConvSnap> = pending
|
|
.into_values()
|
|
.filter(|s| {
|
|
!st.deleted.contains(&s.id)
|
|
&& s.seq > st.written.get(&s.id).copied().unwrap_or(0)
|
|
})
|
|
.collect();
|
|
let queued = std::mem::take(&mut st.res);
|
|
let mut res = Vec::with_capacity(queued.len());
|
|
for j in queued {
|
|
if !st.deleted.contains(tool_res_conv(&j.key)) {
|
|
st.inflight.insert(j.key.clone());
|
|
res.push(j);
|
|
}
|
|
}
|
|
(st.queued, snaps, res)
|
|
};
|
|
|
|
snaps.sort_by_key(|s| s.seq);
|
|
let n_snaps = snaps.len();
|
|
let jobs: Vec<(String, Arc<str>)> = res
|
|
.iter()
|
|
.map(|j| (j.key.clone(), j.plain.clone()))
|
|
.collect();
|
|
let gate = ResGate(sh.st.clone());
|
|
// Un écrivain qui panique ne doit pas laisser la file bloquée : les
|
|
// attentes (fin de tour, reset) ne reviendraient jamais.
|
|
let out = AssertUnwindSafe(sh.sink.commit(snaps, res, gate))
|
|
.catch_unwind()
|
|
.await
|
|
.unwrap_or_else(|_| CommitOutcome {
|
|
wrote: Vec::new(),
|
|
err: Some("écrivain en panique".into()),
|
|
});
|
|
|
|
let mut st = lock(&sh.st);
|
|
for (key, _) in &jobs {
|
|
st.inflight.remove(key);
|
|
st.cancelled.remove(key);
|
|
}
|
|
for (id, seq) in &out.wrote {
|
|
let w = st.written.entry(id.clone()).or_insert(0);
|
|
if *seq > *w {
|
|
*w = *seq;
|
|
}
|
|
}
|
|
match &out.err {
|
|
Some(e) => {
|
|
// Les résultats restent servis depuis la mémoire ; un
|
|
// instantané raté sera remplacé par le suivant.
|
|
eprintln!(
|
|
"[persist] écriture différée échouée ({} instantané(s), {} résultat(s) d'outil gardé(s) en mémoire) : {e}",
|
|
n_snaps.saturating_sub(out.wrote.len()),
|
|
jobs.len()
|
|
);
|
|
}
|
|
None => {
|
|
for (key, plain) in &jobs {
|
|
if st.mem.get(key).is_some_and(|m| Arc::ptr_eq(m, plain)) {
|
|
st.mem.remove(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
st.done = upto;
|
|
sh.done.send_replace(upto);
|
|
}
|
|
}
|
|
|
|
// ---- écriture par l'API interne du Go --------------------------------------
|
|
|
|
/// Écrit les lots par l'API interne du Go : scellés par lui (la clé de la
|
|
/// mémoire ne sort jamais), rangés par lui dans bbolt.
|
|
#[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 {
|
|
async fn commit(
|
|
&self,
|
|
snaps: Vec<ConvSnap>,
|
|
res: Vec<ToolResJob>,
|
|
gate: ResGate,
|
|
) -> CommitOutcome {
|
|
let _index = self.index_lock.lock().await;
|
|
commit_batch(&self.store, snaps, res, gate, now_unix()).await
|
|
}
|
|
}
|
|
|
|
/// `commitPersistBatch` à travers l'API interne. Mémoire verrouillée : rien
|
|
/// n'est écrit en clair — les instantanés sont sautés (le fil reste en RAM),
|
|
/// les résultats d'outils rendent une erreur (ils restent servis depuis la
|
|
/// mémoire). Index illisible (autre clé, abîmé) : les contenus sont écrits,
|
|
/// l'index n'est PAS touché — le réécrire depuis une lecture vide effacerait
|
|
/// les autres discussions de la liste.
|
|
pub async fn commit_batch(
|
|
store: &Store,
|
|
snaps: Vec<ConvSnap>,
|
|
res: Vec<ToolResJob>,
|
|
gate: ResGate,
|
|
now: i64,
|
|
) -> CommitOutcome {
|
|
let mut out = CommitOutcome::default();
|
|
for j in res {
|
|
let sealed = match store.seal(j.plain.as_bytes().to_vec()).await {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
out.err = Some(crypt_msg(e));
|
|
return out;
|
|
}
|
|
};
|
|
if !gate.wanted(&j.key) {
|
|
continue; // supprimé avec sa discussion pendant que le lot partait
|
|
}
|
|
if let Err(e) = store.put(BK_TOOL_RES, &j.key, sealed).await {
|
|
out.err = Some(e);
|
|
return out;
|
|
}
|
|
}
|
|
if snaps.is_empty() {
|
|
return out;
|
|
}
|
|
let mut idx: Option<Vec<ConvMeta>> = match store.get(BK_CHAT, CK_INDEX).await {
|
|
Ok(None) => Some(Vec::new()),
|
|
Ok(Some(raw)) if raw.is_empty() => Some(Vec::new()),
|
|
Ok(Some(raw)) => match store.open(raw).await {
|
|
Ok(dec) => serde_json::from_slice::<Option<Vec<ConvMeta>>>(&dec)
|
|
.ok()
|
|
.map(Option::unwrap_or_default),
|
|
Err(CryptError::Locked) => return out,
|
|
Err(CryptError::Other(_)) => None,
|
|
},
|
|
Err(e) => {
|
|
out.err = Some(e);
|
|
return out;
|
|
}
|
|
};
|
|
for s in &snaps {
|
|
let sealed = match store.seal(s.body.clone()).await {
|
|
Ok(b) => b,
|
|
Err(CryptError::Locked) => return out,
|
|
Err(e) => {
|
|
out.err = Some(crypt_msg(e));
|
|
return out;
|
|
}
|
|
};
|
|
if let Err(e) = store.put(BK_CHAT, &conv_key(&s.id), sealed).await {
|
|
out.err = Some(e);
|
|
return out;
|
|
}
|
|
if let Some(idx) = idx.as_mut() {
|
|
touch_meta_in(idx, s, now);
|
|
}
|
|
out.wrote.push((s.id.clone(), s.seq));
|
|
}
|
|
let Some(idx) = idx else {
|
|
return out;
|
|
};
|
|
match store.seal(encode_index(&idx)).await {
|
|
Ok(sealed) => {
|
|
if let Err(e) = store.put(BK_CHAT, CK_INDEX, sealed).await {
|
|
out.err = Some(e);
|
|
}
|
|
}
|
|
Err(CryptError::Locked) => {}
|
|
Err(e) => out.err = Some(crypt_msg(e)),
|
|
}
|
|
out
|
|
}
|
|
|
|
fn crypt_msg(e: CryptError) -> String {
|
|
match e {
|
|
CryptError::Locked => "mémoire verrouillée".into(),
|
|
CryptError::Other(s) => s,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
use std::sync::Mutex as StdMutex;
|
|
use tokio::sync::{Semaphore, mpsc};
|
|
|
|
fn um(s: &str) -> Message {
|
|
Message {
|
|
role: "user".into(),
|
|
content: Some(json!(s)),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn ev(seq: i64, delta: Value) -> LogEvent {
|
|
let Value::Object(d) = delta else {
|
|
panic!("delta objet attendu")
|
|
};
|
|
LogEvent {
|
|
seq,
|
|
ts: 0,
|
|
delta: d,
|
|
}
|
|
}
|
|
|
|
// ---- format ----
|
|
|
|
#[test]
|
|
fn fil_vide_ecrit_comme_le_go() {
|
|
assert_eq!(
|
|
String::from_utf8(ConvState::default().to_json()).unwrap(),
|
|
r#"{"messages":null,"log":null,"seq":0,"ctx_used":0}"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn etat_aller_retour() {
|
|
let st = ConvState {
|
|
messages: vec![um("bonjour")],
|
|
log: vec![ev(1, json!({"user": "bonjour"}))],
|
|
seq: 1,
|
|
ctx_used: 42,
|
|
proj_snap: Some(ProjSnapshot {
|
|
at: 7,
|
|
key: "k".into(),
|
|
state: ProjState {
|
|
mem: BTreeMap::from([("b".into(), "2".into()), ("a".into(), "1".into())]),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let b = st.to_json();
|
|
// Les compteurs en mémoire ne s'écrivent pas.
|
|
let mem = ConvState {
|
|
ctx_used_len: 3,
|
|
gen_peak: 900,
|
|
..st.clone()
|
|
};
|
|
assert_eq!(mem.to_json(), b);
|
|
assert_eq!(
|
|
String::from_utf8(b.clone()).unwrap(),
|
|
r#"{"messages":[{"role":"user","content":"bonjour"}],"log":[{"seq":1,"ts":0,"delta":{"user":"bonjour"}}],"seq":1,"ctx_used":42,"proj_snap":{"at":7,"state":{"mem":{"a":"1","b":"2"}},"key":"k","sys_hash":""}}"#
|
|
);
|
|
assert_eq!(decode_state(&b), st);
|
|
}
|
|
|
|
#[test]
|
|
fn relecture_tolerante() {
|
|
assert_eq!(decode_state(b""), ConvState::default());
|
|
assert_eq!(decode_state(b"pas du json"), ConvState::default());
|
|
assert_eq!(decode_state(b"[1,2]"), ConvState::default());
|
|
// Un champ mal typé est ignoré seul ; null vaut vide.
|
|
let st = decode_state(
|
|
br#"{"messages":[{"role":"user","content":"a"},{"role":5}],"log":null,"seq":"x","ctx_used":12,"inconnu":1}"#,
|
|
);
|
|
assert_eq!(st.messages, vec![um("a")]);
|
|
assert!(st.log.is_empty());
|
|
assert_eq!(st.seq, 0);
|
|
assert_eq!(st.ctx_used, 12);
|
|
assert_eq!(st.proj_snap, None);
|
|
}
|
|
|
|
#[test]
|
|
fn guerison_des_images_persistees() {
|
|
let b = json!({"messages": [
|
|
{"role": "user", "content": "bonjour"},
|
|
{"role": "user", "content": [
|
|
{"type": "text", "text": "Voici la capture demandée."},
|
|
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,AAAA"}}
|
|
]},
|
|
{"role": "user", "content": [{"type": "text", "text": "sans image"}]}
|
|
]});
|
|
let st = decode_state(b.to_string().as_bytes());
|
|
assert_eq!(st.messages[0].content, Some(json!("bonjour")));
|
|
assert_eq!(
|
|
st.messages[1].content,
|
|
Some(json!(format!(
|
|
"Voici la capture demandée.{IMAGE_LOST_MARKER}"
|
|
)))
|
|
);
|
|
assert_eq!(
|
|
st.messages[2].content,
|
|
Some(json!([{"type": "text", "text": "sans image"}]))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instantane_en_plein_tour_ecrit_le_journal_compacte() {
|
|
let st = ConvState {
|
|
messages: vec![um("titre de la discussion")],
|
|
log: vec![
|
|
ev(1, json!({"user": "titre de la discussion"})),
|
|
ev(2, json!({"content": "Bon"})),
|
|
ev(3, json!({"content": "jour"})),
|
|
],
|
|
seq: 3,
|
|
..Default::default()
|
|
};
|
|
let s = take_snapshot(&st, true, "c1", 9, "generale");
|
|
assert_eq!((s.id.as_str(), s.seq, s.turns), ("c1", 9, 1));
|
|
assert_eq!(s.title, "titre de la discussion");
|
|
assert_eq!(s.project, "generale");
|
|
let back = decode_state(&s.body);
|
|
assert_eq!(back.log, compact_log(&st.log));
|
|
assert_eq!(back.log.len(), 2);
|
|
// Le journal en mémoire n'a pas bougé ; hors tour, il est écrit tel quel.
|
|
assert_eq!(st.log.len(), 3);
|
|
let s = take_snapshot(&st, false, "c1", 10, "");
|
|
assert_eq!(decode_state(&s.body).log, st.log);
|
|
}
|
|
|
|
// ---- index ----
|
|
|
|
#[test]
|
|
fn titre_deduit() {
|
|
assert_eq!(conv_summary(&[]), "");
|
|
let mut nudge = um(super::super::compact::THINK_NUDGE_FIRST);
|
|
assert_eq!(conv_summary(&[nudge.clone(), um("bonjour")]), "bonjour");
|
|
nudge.img_relay = true;
|
|
assert_eq!(conv_summary(&[nudge, um(" a\nb ")]), "a b");
|
|
let assistant = Message {
|
|
role: "assistant".into(),
|
|
content: Some(json!("réponse")),
|
|
..Default::default()
|
|
};
|
|
let multi = Message {
|
|
role: "user".into(),
|
|
content: Some(json!([{"type": "text", "text": "image"}])),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(conv_summary(&[assistant, multi, um(" "), um("ok")]), "ok");
|
|
// 60 caractères, découpés sur les caractères et non les octets.
|
|
let long = "é".repeat(59) + " suite";
|
|
assert_eq!(conv_summary(&[um(&long)]), "é".repeat(59) + "…");
|
|
}
|
|
|
|
#[test]
|
|
fn titre_sans_bloc_projet() {
|
|
let mut m = um(&format!(
|
|
"{}\n+ x\n{}\n\ncorrige le build",
|
|
super::super::compact::CTX_UPD_OPEN,
|
|
super::super::compact::CTX_UPD_CLOSE
|
|
));
|
|
m.ctx_upd = true;
|
|
assert_eq!(conv_summary(&[m]), "corrige le build");
|
|
}
|
|
|
|
#[test]
|
|
fn entree_d_index_rafraichie_ou_ajoutee() {
|
|
let snap = ConvSnap {
|
|
id: "c1".into(),
|
|
seq: 1,
|
|
body: vec![],
|
|
title: "déduit".into(),
|
|
turns: 3,
|
|
project: "p".into(),
|
|
};
|
|
let mut idx = vec![ConvMeta {
|
|
id: "c1".into(),
|
|
title: "choisi".into(),
|
|
created: 1,
|
|
updated: 1,
|
|
turns: 1,
|
|
project: String::new(),
|
|
}];
|
|
touch_meta_in(&mut idx, &snap, 50);
|
|
assert_eq!(
|
|
idx[0],
|
|
ConvMeta {
|
|
id: "c1".into(),
|
|
title: "choisi".into(),
|
|
created: 1,
|
|
updated: 50,
|
|
turns: 3,
|
|
project: String::new()
|
|
}
|
|
);
|
|
idx[0].title.clear();
|
|
touch_meta_in(&mut idx, &snap, 60);
|
|
assert_eq!(idx[0].title, "déduit");
|
|
let other = ConvSnap {
|
|
id: "c2".into(),
|
|
..snap
|
|
};
|
|
touch_meta_in(&mut idx, &other, 70);
|
|
assert_eq!(
|
|
idx[1],
|
|
ConvMeta {
|
|
id: "c2".into(),
|
|
title: "déduit".into(),
|
|
created: 70,
|
|
updated: 70,
|
|
turns: 3,
|
|
project: "p".into()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn index_tri_projets_et_operations() {
|
|
let raw = br#"[{"id":"a","title":"","created":1,"updated":5,"turns":0},
|
|
{"id":"b","title":"B","created":1,"updated":9,"turns":2,"project":"x"},
|
|
{"id":"c","title":"","created":1,"updated":5,"turns":0,"project":" "}]"#;
|
|
let mut idx = decode_index(raw);
|
|
let ids: Vec<&str> = idx.iter().map(|m| m.id.as_str()).collect();
|
|
assert_eq!(ids, ["b", "a", "c"], "plus récente en tête, tri stable");
|
|
assert_eq!(decode_index(b"null"), vec![]);
|
|
assert_eq!(decode_index(b"{"), vec![]);
|
|
|
|
assert_eq!(conv_project_of(&idx[1]), DEFAULT_PROJECT_SLUG);
|
|
let generale: Vec<&str> = index_for_project(&idx, DEFAULT_PROJECT_SLUG)
|
|
.iter()
|
|
.map(|m| m.id.as_str())
|
|
.collect();
|
|
assert_eq!(generale, ["a", "c"]);
|
|
assert!(tag_orphans(&mut idx, "generale"));
|
|
assert!(!tag_orphans(&mut idx, "generale"));
|
|
assert_eq!(idx[2].project, "generale");
|
|
|
|
assert_eq!(rename_in(&mut idx, "a", " Nom "), Ok(()));
|
|
assert_eq!(idx[1].title, "Nom");
|
|
assert_eq!(rename_in(&mut idx, "a", " "), Err(IndexError::Missing));
|
|
assert_eq!(rename_in(&mut idx, "zz", "t"), Err(IndexError::NotFound));
|
|
assert!(remove_from(&mut idx, "b"));
|
|
assert!(!remove_from(&mut idx, "b"));
|
|
assert_eq!(
|
|
String::from_utf8(encode_index(&idx[..1])).unwrap(),
|
|
r#"[{"id":"a","title":"Nom","created":1,"updated":5,"turns":0,"project":"generale"}]"#
|
|
);
|
|
assert_eq!(encode_index(&[]), b"null");
|
|
}
|
|
|
|
#[test]
|
|
fn cles() {
|
|
assert_eq!(conv_key("c1"), "conv:c1");
|
|
assert_eq!(tool_res_conv("c123.ab12"), "c123");
|
|
assert_eq!(tool_res_conv("c1.2.x"), "c1.2");
|
|
assert_eq!(tool_res_conv("nosession"), "nosession");
|
|
assert_eq!(tool_res_conv(".x"), ".x");
|
|
let id = new_conv_id();
|
|
assert!(
|
|
id.starts_with('c') && id[1..].parse::<u128>().is_ok(),
|
|
"{id}"
|
|
);
|
|
}
|
|
|
|
// ---- écrivain ----
|
|
|
|
/// Faux écrivain : enregistre ce qu'il écrit ; retenu au besoin (chaque
|
|
/// lot s'annonce sur `entered` puis attend un jeton de `release`).
|
|
#[derive(Default)]
|
|
struct FakeSink {
|
|
gate: Option<(mpsc::UnboundedSender<Vec<u64>>, Arc<Semaphore>)>,
|
|
seqs: StdMutex<Vec<u64>>,
|
|
disk: StdMutex<HashMap<String, Vec<u8>>>,
|
|
res: StdMutex<HashMap<String, String>>,
|
|
fail: StdMutex<bool>,
|
|
delay: Option<Duration>,
|
|
}
|
|
|
|
impl PersistSink for Arc<FakeSink> {
|
|
fn commit(
|
|
&self,
|
|
snaps: Vec<ConvSnap>,
|
|
res: Vec<ToolResJob>,
|
|
gate: ResGate,
|
|
) -> impl Future<Output = CommitOutcome> + Send {
|
|
let me = self.clone();
|
|
async move {
|
|
if let Some((entered, release)) = &me.gate {
|
|
let _ = entered.send(snaps.iter().map(|s| s.seq).collect());
|
|
release.acquire().await.unwrap().forget();
|
|
}
|
|
if let Some(d) = me.delay {
|
|
tokio::time::sleep(d).await;
|
|
}
|
|
if *me.fail.lock().unwrap() {
|
|
return CommitOutcome {
|
|
wrote: vec![],
|
|
err: Some("disque plein".into()),
|
|
};
|
|
}
|
|
for j in res {
|
|
if gate.wanted(&j.key) {
|
|
me.res.lock().unwrap().insert(j.key, j.plain.to_string());
|
|
}
|
|
}
|
|
let mut out = CommitOutcome::default();
|
|
for s in snaps {
|
|
me.seqs.lock().unwrap().push(s.seq);
|
|
me.disk.lock().unwrap().insert(s.id.clone(), s.body);
|
|
out.wrote.push((s.id, s.seq));
|
|
}
|
|
out
|
|
}
|
|
}
|
|
}
|
|
|
|
fn gated() -> (
|
|
Arc<FakeSink>,
|
|
mpsc::UnboundedReceiver<Vec<u64>>,
|
|
Arc<Semaphore>,
|
|
) {
|
|
let (tx, rx) = mpsc::unbounded_channel();
|
|
let sem = Arc::new(Semaphore::new(0));
|
|
let sink = Arc::new(FakeSink {
|
|
gate: Some((tx, sem.clone())),
|
|
..Default::default()
|
|
});
|
|
(sink, rx, sem)
|
|
}
|
|
|
|
async fn entered(rx: &mut mpsc::UnboundedReceiver<Vec<u64>>) -> Vec<u64> {
|
|
tokio::time::timeout(Duration::from_secs(5), rx.recv())
|
|
.await
|
|
.expect("aucun lot n'a atteint l'écriture")
|
|
.unwrap()
|
|
}
|
|
|
|
fn snap(id: &str, seq: u64, body: &str) -> ConvSnap {
|
|
ConvSnap {
|
|
id: id.into(),
|
|
seq,
|
|
body: body.as_bytes().to_vec(),
|
|
title: String::new(),
|
|
turns: 0,
|
|
project: String::new(),
|
|
}
|
|
}
|
|
|
|
fn job(key: &str, plain: &str) -> ToolResJob {
|
|
ToolResJob {
|
|
key: key.into(),
|
|
plain: Arc::from(plain),
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn jamais_un_etat_plus_ancien() {
|
|
let sink = Arc::new(FakeSink {
|
|
delay: Some(Duration::from_millis(2)),
|
|
..Default::default()
|
|
});
|
|
let p = Persister::new(sink.clone());
|
|
// L'état et son numéro sont pris sous le même verrou, comme sous c.mu.
|
|
let state = Arc::new(StdMutex::new(0u32));
|
|
let mut tasks = Vec::new();
|
|
for _ in 0..8 {
|
|
let (p, state) = (p.clone(), state.clone());
|
|
tasks.push(tokio::spawn(async move {
|
|
for _ in 0..10 {
|
|
let s = {
|
|
let mut n = state.lock().unwrap();
|
|
*n += 1;
|
|
snap("c1", p.next_seq(), &n.to_string())
|
|
};
|
|
p.enqueue_snap(s);
|
|
tokio::task::yield_now().await;
|
|
}
|
|
}));
|
|
}
|
|
for t in tasks {
|
|
t.await.unwrap();
|
|
}
|
|
p.flush().await;
|
|
let seqs = sink.seqs.lock().unwrap().clone();
|
|
assert!(
|
|
seqs.windows(2).all(|w| w[1] > w[0]),
|
|
"un état plus ancien a écrasé un plus récent : {seqs:?}"
|
|
);
|
|
assert_eq!(sink.disk.lock().unwrap()["c1"], b"80");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn persist_attend_l_ecriture_en_vol() {
|
|
let (sink, mut rx, release) = gated();
|
|
let p = Persister::new(sink.clone());
|
|
p.enqueue_snap(snap("c1", 1, "avant"));
|
|
assert_eq!(entered(&mut rx).await, vec![1]);
|
|
let p2 = p.clone();
|
|
let done = tokio::spawn(async move { p2.persist(snap("c1", 2, "vide")).await });
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
assert!(
|
|
!done.is_finished(),
|
|
"revenu avant la fin de l'écriture en vol"
|
|
);
|
|
release.add_permits(100);
|
|
tokio::time::timeout(Duration::from_secs(5), done)
|
|
.await
|
|
.expect("persist bloqué")
|
|
.unwrap();
|
|
assert_eq!(sink.disk.lock().unwrap()["c1"], b"vide");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn instantane_en_attente_remplace_et_ancien_ignore() {
|
|
let (sink, mut rx, release) = gated();
|
|
let p = Persister::new(sink.clone());
|
|
p.enqueue_snap(snap("c1", 1, "un"));
|
|
entered(&mut rx).await;
|
|
p.enqueue_snap(snap("c1", 3, "trois"));
|
|
p.enqueue_snap(snap("c1", 2, "deux")); // plus ancien que l'attente : écarté
|
|
release.add_permits(100);
|
|
assert_eq!(entered(&mut rx).await, vec![3]);
|
|
p.flush().await;
|
|
// Déjà écrit plus récent : un instantané plus ancien ne part pas.
|
|
p.enqueue_snap(snap("c1", 2, "deux"));
|
|
p.flush().await;
|
|
assert_eq!(*sink.seqs.lock().unwrap(), vec![1, 3]);
|
|
assert_eq!(sink.disk.lock().unwrap()["c1"], b"trois");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resultat_servi_avant_l_ecriture() {
|
|
let (sink, mut rx, release) = gated();
|
|
let p = Persister::new(sink.clone());
|
|
let long = "sortie\n".repeat(1000);
|
|
p.enqueue_tool_res(job("c1.a", &long));
|
|
entered(&mut rx).await;
|
|
assert_eq!(p.tool_res_pending("c1.a").as_deref(), Some(long.as_str()));
|
|
release.add_permits(100);
|
|
p.flush().await;
|
|
assert!(
|
|
p.tool_res_pending("c1.a").is_none(),
|
|
"copie en mémoire restée"
|
|
);
|
|
assert_eq!(sink.res.lock().unwrap()["c1.a"], long);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resultat_supprime_avant_puis_pendant_l_ecriture() {
|
|
let (sink, mut rx, release) = gated();
|
|
let p = Persister::new(sink.clone());
|
|
p.enqueue_tool_res(job("c1.a", "a")); // retient l'écrivain
|
|
entered(&mut rx).await;
|
|
p.enqueue_tool_res(job("c2.b", "b")); // en attente derrière
|
|
p.drop_tool_res("c2.");
|
|
p.drop_tool_res("c1."); // déjà pris par le lot en vol : annulé
|
|
assert!(p.tool_res_pending("c1.a").is_none());
|
|
release.add_permits(100);
|
|
p.flush().await;
|
|
assert!(
|
|
sink.res.lock().unwrap().is_empty(),
|
|
"un résultat supprimé a été écrit"
|
|
);
|
|
// L'annulation ne vaut que pour ce lot.
|
|
p.enqueue_tool_res(job("c1.c", "c"));
|
|
p.flush().await;
|
|
assert!(sink.res.lock().unwrap().contains_key("c1.c"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn suppression_ne_ressuscite_rien() {
|
|
let (sink, mut rx, release) = gated();
|
|
let p = Persister::new(sink.clone());
|
|
p.enqueue_snap(snap("b", 1, "b"));
|
|
entered(&mut rx).await; // un lot est en vol
|
|
p.enqueue_snap(snap("a", 2, "a"));
|
|
p.enqueue_tool_res(job("a.x", "x"));
|
|
p.forget("a");
|
|
p.enqueue_snap(snap("a", 3, "a encore")); // après la suppression : ignoré
|
|
p.enqueue_tool_res(job("a.y", "y"));
|
|
release.add_permits(100);
|
|
p.flush().await;
|
|
assert!(!sink.disk.lock().unwrap().contains_key("a"));
|
|
assert!(sink.res.lock().unwrap().is_empty());
|
|
assert!(p.tool_res_pending("a.y").is_none());
|
|
assert_eq!(sink.disk.lock().unwrap()["b"], b"b");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn echec_d_ecriture_garde_les_resultats_en_memoire() {
|
|
let sink = Arc::new(FakeSink::default());
|
|
*sink.fail.lock().unwrap() = true;
|
|
let p = Persister::new(sink.clone());
|
|
p.enqueue_snap(snap("c1", 1, "un"));
|
|
p.enqueue_tool_res(job("c1.a", "a"));
|
|
p.flush().await;
|
|
assert_eq!(p.tool_res_pending("c1.a").as_deref(), Some("a"));
|
|
// Le même instantané repart au suivant (rien n'a été marqué écrit).
|
|
*sink.fail.lock().unwrap() = false;
|
|
p.enqueue_snap(snap("c1", 1, "un"));
|
|
p.flush().await;
|
|
assert_eq!(sink.disk.lock().unwrap()["c1"], b"un");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ecrivain_en_panique_ne_bloque_pas_la_file() {
|
|
struct Panique;
|
|
impl PersistSink for Panique {
|
|
async fn commit(
|
|
&self,
|
|
_: Vec<ConvSnap>,
|
|
_: Vec<ToolResJob>,
|
|
_: ResGate,
|
|
) -> CommitOutcome {
|
|
panic!("boum")
|
|
}
|
|
}
|
|
let p = Persister::new(Panique);
|
|
p.enqueue_snap(snap("c1", 1, "un"));
|
|
assert!(
|
|
p.flush_timeout(Duration::from_secs(5)).await,
|
|
"file bloquée"
|
|
);
|
|
}
|
|
}
|