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>
101 lines
4.1 KiB
Rust
101 lines
4.1 KiB
Rust
//! [`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(())
|
|
}
|
|
}
|