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>
96 lines
3.8 KiB
Rust
96 lines
3.8 KiB
Rust
//! [`RotateConversationLog`] — rotation **best-effort** du log d'une conversation (lot LS6).
|
|
//!
|
|
//! Déclenchée **hors chemin chaud** (à la reprise/ouverture d'un fil, jamais par un
|
|
//! `append`), elle borne le segment actif en archivant sa tête froide. L'invariant pivot
|
|
//! (INV-LS6) est garanti par le **plancher** : `keep_from = up_to` du handoff courant ⇒
|
|
//! aucun tour `≥ up_to` n'est jamais archivé, donc `read(since = up_to)` (fold incrémental)
|
|
//! et la reprise restent corrects.
|
|
//!
|
|
//! Best-effort strict : pas de handoff (ou `up_to` nil) ⇒ skip ; toute erreur store est
|
|
//! typée et **avalée par l'appelant** (le câblage app-tauri), jamais propagée en échec de
|
|
//! reprise.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use domain::project::ProjectPath;
|
|
use domain::{rotation_plan, ConversationId, RotationDecision, RotationThresholds};
|
|
|
|
use crate::conversation::ConversationArchiveProvider;
|
|
use crate::error::AppError;
|
|
use crate::HandoffProvider;
|
|
|
|
/// Entrée de [`RotateConversationLog::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RotateConversationLogInput {
|
|
/// Le project root dont dériver l'archive et le handoff.
|
|
pub project_root: ProjectPath,
|
|
/// La conversation (paire) à roter.
|
|
pub conversation: ConversationId,
|
|
}
|
|
|
|
/// Use case de rotation du log (lot LS6) — consomme un [`ConversationArchive`] et le
|
|
/// [`HandoffStore`] (pour lire `floor = up_to`), tous deux résolus par root.
|
|
///
|
|
/// [`ConversationArchive`]: domain::ConversationArchive
|
|
/// [`HandoffStore`]: domain::HandoffStore
|
|
pub struct RotateConversationLog {
|
|
archives: Arc<dyn ConversationArchiveProvider>,
|
|
handoffs: Arc<dyn HandoffProvider>,
|
|
thresholds: RotationThresholds,
|
|
}
|
|
|
|
impl RotateConversationLog {
|
|
/// Construit le use case à partir des providers par root (seuils par défaut).
|
|
#[must_use]
|
|
pub fn new(
|
|
archives: Arc<dyn ConversationArchiveProvider>,
|
|
handoffs: Arc<dyn HandoffProvider>,
|
|
) -> Self {
|
|
Self {
|
|
archives,
|
|
handoffs,
|
|
thresholds: RotationThresholds::defaults(),
|
|
}
|
|
}
|
|
|
|
/// Applique la politique de rotation à `conversation`, si nécessaire.
|
|
///
|
|
/// Étapes : résoudre l'archive + le handoff store du root ; lire le plancher
|
|
/// `floor = up_to` (pas de handoff ⇒ `None` ⇒ skip) ; lire les `stats` bon marché du
|
|
/// segment actif ; décider via [`rotation_plan`] ; archiver
|
|
/// (`rotate(keep_from = up_to)`) seulement si la décision est `Archive`.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Store`] si la lecture du handoff/stats ou la rotation échoue. L'appelant
|
|
/// (câblage) **avale** cette erreur : la rotation ne doit jamais casser une reprise.
|
|
pub async fn execute(&self, input: RotateConversationLogInput) -> Result<(), AppError> {
|
|
// Sans provider d'archive/handoff câblé pour ce root ⇒ rien à faire (best-effort).
|
|
let Some(archive) = self.archives.conversation_archive_for(&input.project_root) else {
|
|
return Ok(());
|
|
};
|
|
let Some(handoff_store) = self.handoffs.handoff_store_for(&input.project_root) else {
|
|
return Ok(());
|
|
};
|
|
|
|
// Plancher = `up_to` du handoff courant (pas de handoff ⇒ None ⇒ skip plus bas).
|
|
let floor = handoff_store
|
|
.load(input.conversation)
|
|
.await?
|
|
.map(|handoff| handoff.up_to);
|
|
|
|
let stats = archive.stats(input.conversation).await?;
|
|
match rotation_plan(
|
|
stats.active_turns,
|
|
stats.active_bytes,
|
|
floor,
|
|
self.thresholds,
|
|
) {
|
|
RotationDecision::Skip => Ok(()),
|
|
RotationDecision::Archive { keep_from } => archive
|
|
.rotate(input.conversation, keep_from)
|
|
.await
|
|
.map_err(AppError::from),
|
|
}
|
|
}
|
|
}
|