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:
2026-06-22 12:58:40 +02:00
parent f9231422fe
commit 40ca3e522f
13 changed files with 2095 additions and 43 deletions

View File

@ -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();