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:
100
crates/application/src/conversation/record.rs
Normal file
100
crates/application/src/conversation/record.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user