//! [`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, handoffs: Arc, thresholds: RotationThresholds, } impl RotateConversationLog { /// Construit le use case à partir des providers par root (seuils par défaut). #[must_use] pub fn new( archives: Arc, handoffs: Arc, ) -> 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), } } }