feat(persistence): P6a — use case RecordTurn (checkpoint fin de tour, isolé)

Logique du checkpoint conversationnel isolée et testable, sans câblage live :
append du tour au log canonique → load handoff → fold incrémental (tour neuf
seul) → save. Mapping StoreError→AppError via From existant. Debounce repoussé.

- application/src/conversation/{mod,record}.rs : RecordTurn::record
- tests : 6 cas (incrémentalité prouvée via summarizer-espion, propagation
  d'erreur sans effet de bord, isolation par conversation) ; suite verte

Câblage sur les seams réels (reply/ask) + composition root = P6b.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:20:05 +02:00
parent f3046f3dd8
commit 2a5873dcf0
4 changed files with 480 additions and 0 deletions

View File

@ -0,0 +1,18 @@
//! Conversation persistence use cases (cadrage « persistance conversationnelle »,
//! ARCHITECTURE §19, lot P6).
//!
//! Where [`crate::agent::lifecycle`] owns the *runtime* of an agent (spawn, PTY,
//! sessions), this module owns the **durable conversational memory** of a
//! conversation (paire) : the canonical append-only log and its incremental
//! handoff. The single use case here, [`RecordTurn`], materialises the
//! **end-of-turn checkpoint** (D19-5) by talking **only** to the three driven/driving
//! ports of §19 ([`domain::ConversationLog`], [`domain::HandoffStore`],
//! [`domain::HandoffSummarizer`]) — never to a concrete adapter.
//!
//! This is the **isolated logic** of P6 (lot P6a) : no wiring to the orchestrator,
//! the PTY or `app-tauri` (that is P6b). It is fully testable with in-memory fakes
//! of the three ports.
mod record;
pub use record::RecordTurn;

View File

@ -0,0 +1,100 @@
//! [`RecordTurn`] — the end-of-turn checkpoint use case (ARCHITECTURE §19, lot P6a).
use std::sync::Arc;
use domain::{ConversationId, ConversationLog, ConversationTurn, HandoffStore, HandoffSummarizer};
use crate::error::AppError;
/// Records one conversation turn at a checkpoint: appends it to the canonical
/// log, then advances the incremental handoff (ARCHITECTURE §19, décision D19-5).
///
/// **Single Responsibility**: materialise *one* end-of-turn checkpoint. It is the
/// only place that couples the append-only log (source of truth, [`ConversationLog`])
/// with the cumulative resume summary ([`HandoffStore`]) via the incremental
/// [`HandoffSummarizer`]. It consumes **exactly** those three ports (Interface
/// Segregation), injected as `Arc<dyn …>` at the composition root.
///
/// ## Incremental by contract (no full re-read)
///
/// Each call folds **only the new turn** into the previous handoff
/// (`fold(prev, &[turn])`), never re-reading the whole thread — exactly the seam
/// [`HandoffSummarizer`] was designed for (§19, lot P4). The resulting handoff's
/// [`Handoff::up_to`](domain::Handoff::up_to) is the recorded turn's id (guaranteed
/// by the summarizer, P4).
///
/// ## Best-effort fold, honest `Result`
///
/// The fold itself cannot fail (it returns no `Result`, P4 — a future LLM
/// summarizer falls back rather than erroring), so the only failures are the
/// store operations (`append` / `load` / `save`), which propagate as [`AppError`].
/// This use case stays **honest** and surfaces them; the live wiring (P6b) decides
/// whether to swallow a handoff failure rather than block the turn.
///
/// ## No debounce here (deferred)
///
/// One `append` + one `save` per recorded turn. The debounce optimisation (coalesce
/// several rapid turns into a single handoff recompute) is intentionally **deferred**
/// — it belongs to the live wiring (P6b), not to this isolated logic.
pub struct RecordTurn {
log: Arc<dyn ConversationLog>,
handoffs: Arc<dyn HandoffStore>,
summarizer: Arc<dyn HandoffSummarizer>,
}
impl RecordTurn {
/// Builds the use case from its three injected ports.
#[must_use]
pub fn new(
log: Arc<dyn ConversationLog>,
handoffs: Arc<dyn HandoffStore>,
summarizer: Arc<dyn HandoffSummarizer>,
) -> Self {
Self {
log,
handoffs,
summarizer,
}
}
/// Records `turn` in `conversation` at an end-of-turn checkpoint.
///
/// Steps (ordering is contractual):
/// 1. **append** the turn to the canonical log (source of truth) ;
/// 2. **load** the previous handoff (`None` on a first checkpoint) ;
/// 3. **fold** `prev` with the single new turn (incremental, best-effort) ;
/// 4. **save** the advanced handoff.
///
/// The append happens **first** so the durable source of truth is never behind
/// the derived handoff. The turn is cloned for the append because the fold (step
/// 3) borrows it as the incremental slice.
///
/// # Errors
/// [`AppError::Store`] (or [`AppError::NotFound`] for a missing store item) when
/// the canonical log append, the handoff load, or the handoff save fails — every
/// [`domain::ports::StoreError`] is mapped through the existing `From` impl. The
/// fold never fails (P4).
pub async fn record(
&self,
conversation: ConversationId,
turn: ConversationTurn,
) -> Result<(), AppError> {
// 1. Append to the canonical append-only log (source of truth, D19-1a).
self.log.append(conversation, turn.clone()).await?;
// 2. Load the previous resume point (absence is never an error, P3).
let prev = self.handoffs.load(conversation).await?;
// 3. Fold the previous handoff with *only* the new turn (incremental, P4):
// no full re-read of the log; best-effort, so no `Result` to propagate.
let handoff = self
.summarizer
.fold(prev, std::slice::from_ref(&turn))
.await;
// 4. Persist the advanced handoff (overwrites the previous resume point).
self.handoffs.save(conversation, handoff).await?;
Ok(())
}
}

View File

@ -12,6 +12,7 @@
#![warn(missing_docs)]
pub mod agent;
pub mod conversation;
pub mod embedder;
pub mod error;
pub mod git;
@ -40,6 +41,7 @@ pub use agent::{
SaveProfileInput, SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext,
UpdateAgentContextInput, send_blocking, AGENT_MEMORY_RECALL_BUDGET,
};
pub use conversation::RecordTurn;
pub use embedder::{
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, DismissChoice,