feat(domain,infra,app): rotation/rétention log.jsonl + lecture paginée (LS6)
Backend uniquement (UI React repoussée à LS7) : - domain : port ConversationArchive + structs SegmentStats/PageCursor/PageDirection/ TurnSlice/RotationDecision/RotationThresholds, fn pures rotation_plan/clamp_page_limit + consts ; re-exports lib. - infrastructure : impl ConversationArchive pour FsConversationLog (stats/rotate/page + helpers), archive segmentée hors chemin chaud. - application : ReadConversationPage + DTO + ConversationArchiveProvider (conversation/paginate), RotateConversationLog (conversation/rotate), exports mod/lib. - app-tauri : AppConversationArchiveProvider + wiring (state), rotation détachée dans launch_agent + commande read_conversation_page (commands), DTOs (dto), commande enregistrée (generate_handler!). - tests (QA, verts) : conversation_log, conversation_rotate_paginate (nouveau), dto (module test). Pivots INV-LS6 et cohérence fold-après-rotation verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -451,6 +451,208 @@ pub trait ProviderSessionStore: Send + Sync {
|
||||
) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// Rotation & pagination du log (lot LS6)
|
||||
// =====================================================================================
|
||||
//
|
||||
// Le `log.jsonl` actif est **borné** par rotation : au-delà d'un seuil (tours OU octets)
|
||||
// la **tête froide** est déplacée dans des **segments d'archive** (`log.<k>.jsonl`), pour
|
||||
// que le segment actif (et donc tout `read`/`last`/append) reste petit. La lecture
|
||||
// **humaine** paginée traverse archives + actif.
|
||||
//
|
||||
// ## Invariant pivot (INV-LS6)
|
||||
//
|
||||
// La rotation ne déplace/élague **JAMAIS** un tour d'id ≥ `up_to` du handoff courant : le
|
||||
// tour `up_to` et tous ses postérieurs restent dans le segment **actif**. Sans handoff (ou
|
||||
// `up_to` = sentinelle nil de LS5) ⇒ **aucune** rotation. C'est ce qui garantit que
|
||||
// [`ConversationLog::read`] avec `since = up_to` (fold incrémental) et la reprise restent
|
||||
// corrects après rotation.
|
||||
|
||||
/// Seuil de rotation en **nombre de tours** du segment actif : au-delà, la tête froide
|
||||
/// est archivée (lot LS6).
|
||||
pub const ROTATE_AFTER_TURNS: usize = 500;
|
||||
|
||||
/// Seuil de rotation en **octets** du segment actif (1 MiB) : au-delà, la tête froide est
|
||||
/// archivée (lot LS6). L'un OU l'autre seuil déclenche.
|
||||
pub const ROTATE_AFTER_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
/// Nombre maximum de **segments d'archive** conservés par conversation (lot LS6). Au-delà,
|
||||
/// le segment d'archive **le plus ancien** est supprimé (backstop) ; jamais l'actif,
|
||||
/// jamais un segment contenant un tour ≥ `up_to` (les archives n'en contiennent jamais).
|
||||
pub const MAX_ARCHIVE_SEGMENTS: usize = 20;
|
||||
|
||||
/// Taille de page par défaut de la lecture humaine paginée (lot LS6), appliquée quand
|
||||
/// l'appelant ne précise pas de limite (`limit == 0`).
|
||||
pub const PAGE_DEFAULT_LIMIT: usize = 50;
|
||||
|
||||
/// Borne **dure** de la taille d'une page (lot LS6) : toute limite est clampée à au plus
|
||||
/// cette valeur (anti-dump, la lecture humaine reste paginée).
|
||||
pub const PAGE_MAX_LIMIT: usize = 200;
|
||||
|
||||
/// Seuils de rotation passés à [`rotation_plan`] (lot LS6). Regroupés pour garder la
|
||||
/// fonction pure simple et testable avec des seuils ad hoc.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RotationThresholds {
|
||||
/// Seuil en nombre de tours du segment actif (cf. [`ROTATE_AFTER_TURNS`]).
|
||||
pub max_turns: usize,
|
||||
/// Seuil en octets du segment actif (cf. [`ROTATE_AFTER_BYTES`]).
|
||||
pub max_bytes: u64,
|
||||
}
|
||||
|
||||
impl RotationThresholds {
|
||||
/// Les seuils par défaut du projet ([`ROTATE_AFTER_TURNS`] / [`ROTATE_AFTER_BYTES`]).
|
||||
#[must_use]
|
||||
pub const fn defaults() -> Self {
|
||||
Self {
|
||||
max_turns: ROTATE_AFTER_TURNS,
|
||||
max_bytes: ROTATE_AFTER_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistiques **bon marché** du segment actif d'une conversation (lot LS6), servant de
|
||||
/// **déclencheur** de rotation sans relire/élaguer quoi que ce soit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SegmentStats {
|
||||
/// Nombre de tours (lignes valides) du segment actif.
|
||||
pub active_turns: usize,
|
||||
/// Taille en octets du segment actif.
|
||||
pub active_bytes: u64,
|
||||
}
|
||||
|
||||
/// Sens de pagination d'une page humaine (lot LS6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PageDirection {
|
||||
/// Vers les tours **plus récents** (postérieurs à l'ancre).
|
||||
Forward,
|
||||
/// Vers les tours **plus anciens** (antérieurs à l'ancre).
|
||||
Backward,
|
||||
}
|
||||
|
||||
/// Curseur d'une lecture paginée (lot LS6).
|
||||
///
|
||||
/// `anchor = None` ⇒ on part d'un **bout** du fil : `Forward` ⇒ le tout début (plus
|
||||
/// anciens), `Backward` ⇒ la toute fin (plus récents). `anchor = Some(id)` ⇒ on pagine
|
||||
/// **strictement** autour de ce tour, dans la `direction` donnée.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PageCursor {
|
||||
/// Le tour pivot, ou `None` pour partir d'un bout du fil.
|
||||
pub anchor: Option<TurnId>,
|
||||
/// Le sens de progression.
|
||||
pub direction: PageDirection,
|
||||
}
|
||||
|
||||
/// Une tranche paginée de tours (lot LS6), **toujours en ordre chronologique croissant**.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TurnSlice {
|
||||
/// Les tours de la page, du plus ancien au plus récent.
|
||||
pub turns: Vec<ConversationTurn>,
|
||||
/// `true` s'il existe d'autres tours **dans le sens de progression** au-delà de la page.
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// La décision **pure** de rotation calculée par [`rotation_plan`] (lot LS6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RotationDecision {
|
||||
/// Ne rien faire (sous les seuils, ou pas de plancher `up_to`).
|
||||
Skip,
|
||||
/// Archiver la tête froide, en **gardant** tout à partir de `keep_from` dans l'actif.
|
||||
Archive {
|
||||
/// Le plancher : premier tour conservé dans l'actif (= `up_to` du handoff). Tous
|
||||
/// les tours **strictement antérieurs** sont archivés ; `keep_from` et ses
|
||||
/// postérieurs restent actifs (INV-LS6).
|
||||
keep_from: TurnId,
|
||||
},
|
||||
}
|
||||
|
||||
/// Décide **purement** s'il faut archiver la tête froide du segment actif (lot LS6).
|
||||
///
|
||||
/// - `floor = None` ⇒ [`RotationDecision::Skip`] : sans plancher `up_to` (pas de handoff),
|
||||
/// on ne rote jamais (INV-LS6). De même si `floor` est la **sentinelle nil** de LS5
|
||||
/// (`up_to` d'un handoff vide) : rien à garder en aval de façon fiable ⇒ `Skip`.
|
||||
/// - Sous **les deux** seuils ⇒ `Skip`.
|
||||
/// - Au-dessus de **l'un** des seuils ⇒ [`RotationDecision::Archive`] avec
|
||||
/// `keep_from = floor` : `keep_from` n'est **jamais** antérieur à `up_to` (il **est**
|
||||
/// `up_to`), donc l'invariant pivot est respecté par construction.
|
||||
///
|
||||
/// Pure et idempotente (mêmes entrées ⇒ même sortie ; aucune I/O).
|
||||
#[must_use]
|
||||
pub fn rotation_plan(
|
||||
active_turns: usize,
|
||||
active_bytes: u64,
|
||||
floor: Option<TurnId>,
|
||||
thresholds: RotationThresholds,
|
||||
) -> RotationDecision {
|
||||
// Pas de plancher, ou plancher = sentinelle nil (handoff vide LS5) ⇒ jamais de rotation.
|
||||
let Some(keep_from) = floor else {
|
||||
return RotationDecision::Skip;
|
||||
};
|
||||
if keep_from.as_uuid().is_nil() {
|
||||
return RotationDecision::Skip;
|
||||
}
|
||||
let over_turns = active_turns > thresholds.max_turns;
|
||||
let over_bytes = active_bytes > thresholds.max_bytes;
|
||||
if over_turns || over_bytes {
|
||||
RotationDecision::Archive { keep_from }
|
||||
} else {
|
||||
RotationDecision::Skip
|
||||
}
|
||||
}
|
||||
|
||||
/// Clampe une taille de page demandée dans `[1, PAGE_MAX_LIMIT]` (lot LS6), en appliquant
|
||||
/// [`PAGE_DEFAULT_LIMIT`] quand l'appelant ne précise rien (`limit == 0`). Pure/testable.
|
||||
#[must_use]
|
||||
pub fn clamp_page_limit(limit: usize) -> usize {
|
||||
let limit = if limit == 0 {
|
||||
PAGE_DEFAULT_LIMIT
|
||||
} else {
|
||||
limit
|
||||
};
|
||||
limit.clamp(1, PAGE_MAX_LIMIT)
|
||||
}
|
||||
|
||||
/// Le port d'**archivage & pagination** du log d'une conversation (port driven, lot LS6).
|
||||
///
|
||||
/// Complète [`ConversationLog`] (append/read/last, **inchangé**) sans le modifier : la
|
||||
/// rotation est une opération **hors chemin chaud** (jamais déclenchée par un `append`) et
|
||||
/// la pagination est une lecture **humaine** archive-aware. Implémenté par le **même**
|
||||
/// adapter FS que [`ConversationLog`] (il détient déjà les fichiers).
|
||||
///
|
||||
/// `#[async_trait]` + erreur [`StoreError`] comme les ports voisins ; injecté en
|
||||
/// `Arc<dyn ConversationArchive>` au composition root, gardé object-safe.
|
||||
#[async_trait::async_trait]
|
||||
pub trait ConversationArchive: Send + Sync {
|
||||
/// Statistiques bon marché du segment actif (déclencheur de rotation).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] en cas d'échec de lecture.
|
||||
async fn stats(&self, conversation: ConversationId) -> Result<SegmentStats, StoreError>;
|
||||
|
||||
/// Archive la tête froide en **gardant** tout à partir de `keep_from` dans l'actif
|
||||
/// (INV-LS6). Idempotente ; ne perd jamais de tour ; n'archive jamais un tour
|
||||
/// `≥ keep_from`. No-op si `keep_from` est absent de l'actif (déjà roté/inconnu).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] en cas d'échec d'I/O.
|
||||
async fn rotate(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
keep_from: TurnId,
|
||||
) -> Result<(), StoreError>;
|
||||
|
||||
/// Lit une **page humaine** (archive-aware), toujours en ordre chronologique croissant,
|
||||
/// selon le `cursor` et la `limit` (clampée `[1, PAGE_MAX_LIMIT]`).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] en cas d'échec de lecture.
|
||||
async fn page(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
cursor: PageCursor,
|
||||
limit: usize,
|
||||
) -> Result<TurnSlice, StoreError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -707,6 +909,87 @@ mod tests {
|
||||
assert!(once.summary_md.chars().count() <= 200);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rotation_plan + clamp_page_limit (lot LS6) — fonctions pures
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn rotation_plan_skips_without_floor() {
|
||||
// Largement au-dessus des seuils mais pas de plancher ⇒ jamais de rotation.
|
||||
let d = rotation_plan(10_000, 10 << 20, None, RotationThresholds::defaults());
|
||||
assert_eq!(d, RotationDecision::Skip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_plan_skips_with_nil_sentinel_floor() {
|
||||
let nil = TurnId::from_uuid(uuid::Uuid::nil());
|
||||
let d = rotation_plan(10_000, 10 << 20, Some(nil), RotationThresholds::defaults());
|
||||
assert_eq!(
|
||||
d,
|
||||
RotationDecision::Skip,
|
||||
"sentinelle nil ⇒ pas de rotation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_plan_skips_under_both_thresholds() {
|
||||
let floor = turn_id(7);
|
||||
let d = rotation_plan(10, 1_000, Some(floor), RotationThresholds::defaults());
|
||||
assert_eq!(d, RotationDecision::Skip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_plan_archives_over_either_threshold_keeping_floor() {
|
||||
let floor = turn_id(7);
|
||||
// Au-dessus du seuil de tours seulement.
|
||||
assert_eq!(
|
||||
rotation_plan(
|
||||
ROTATE_AFTER_TURNS + 1,
|
||||
0,
|
||||
Some(floor),
|
||||
RotationThresholds::defaults()
|
||||
),
|
||||
RotationDecision::Archive { keep_from: floor }
|
||||
);
|
||||
// Au-dessus du seuil d'octets seulement.
|
||||
assert_eq!(
|
||||
rotation_plan(
|
||||
0,
|
||||
ROTATE_AFTER_BYTES + 1,
|
||||
Some(floor),
|
||||
RotationThresholds::defaults()
|
||||
),
|
||||
RotationDecision::Archive { keep_from: floor }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_plan_is_idempotent() {
|
||||
let floor = turn_id(7);
|
||||
let inputs = (ROTATE_AFTER_TURNS + 5, ROTATE_AFTER_BYTES + 5);
|
||||
let once = rotation_plan(
|
||||
inputs.0,
|
||||
inputs.1,
|
||||
Some(floor),
|
||||
RotationThresholds::defaults(),
|
||||
);
|
||||
let twice = rotation_plan(
|
||||
inputs.0,
|
||||
inputs.1,
|
||||
Some(floor),
|
||||
RotationThresholds::defaults(),
|
||||
);
|
||||
assert_eq!(once, twice);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_page_limit_applies_default_and_bounds() {
|
||||
assert_eq!(clamp_page_limit(0), PAGE_DEFAULT_LIMIT, "0 ⇒ défaut");
|
||||
assert_eq!(clamp_page_limit(1), 1);
|
||||
assert_eq!(clamp_page_limit(50), 50);
|
||||
assert_eq!(clamp_page_limit(10_000), PAGE_MAX_LIMIT, "clampé au max");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_then_read_all_preserves_insertion_order() {
|
||||
let log = InMemoryConversationLog::default();
|
||||
|
||||
Reference in New Issue
Block a user