feat(domain,infra,app): rotation/rétention log.jsonl + lecture paginée (LS6)

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>
This commit is contained in:
2026-06-22 12:58:40 +02:00
parent f9231422fe
commit 40ca3e522f
13 changed files with 2095 additions and 43 deletions

View File

@ -13,8 +13,9 @@ use application::{
ConversationTurnWorkPreview, ConversationWorkSummary, CreateProjectInput, CreateProjectOutput,
GitGraphOutput, HealthInput, HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind,
OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus,
TurnPage, TurnSource, TurnView,
};
use domain::{AgentBusyState, Project, ProjectId, TurnRole};
use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole};
/// Request DTO for the `health` command.
#[derive(Debug, Clone, Default, Deserialize)]
@ -2599,3 +2600,249 @@ pub fn parse_memory_slug(raw: &str) -> Result<MemorySlug, ErrorDto> {
message: format!("invalid memory slug: {raw}"),
})
}
// ── Conversation pagination (lot LS6) ────────────────────────────────────────
/// Request DTO for `read_conversation_page` — a human, paginated transcript read.
///
/// `anchor` is a turn id to paginate around (omit to start from a thread end);
/// `direction` is `"forward"` (towards newer) or `"backward"` (towards older,
/// default — the human view opens on the latest page); `limit` is clamped by the
/// domain to `[1, 200]` (omit/`0` ⇒ default 50).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadConversationPageRequestDto {
/// The owning project's id.
pub project_id: String,
/// The conversation (pair) id to read.
pub conversation_id: String,
/// Optional anchor turn id; omit to start from a thread end.
#[serde(default)]
pub anchor: Option<String>,
/// Pagination direction (`"forward"` or `"backward"`); defaults to backward.
#[serde(default)]
pub direction: Option<String>,
/// Requested page size (omit/`0` ⇒ default; clamped to `[1, 200]`).
#[serde(default)]
pub limit: Option<usize>,
}
impl ReadConversationPageRequestDto {
/// Builds the domain [`PageCursor`] from the request (default direction backward,
/// an unparseable anchor degrades to `None` = a thread-end page).
#[must_use]
pub fn cursor(&self) -> PageCursor {
let direction = match self.direction.as_deref() {
Some(d) if d.eq_ignore_ascii_case("forward") => PageDirection::Forward,
_ => PageDirection::Backward,
};
let anchor = self
.anchor
.as_deref()
.and_then(|raw| uuid::Uuid::parse_str(raw).ok())
.map(domain::TurnId::from_uuid);
PageCursor { anchor, direction }
}
}
/// Origin of a turn in the human transcript view (mirrors [`TurnSource`]).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TurnSourceDto {
/// The human operator.
Human,
/// Another agent (delegation via `idea_ask_agent`).
#[serde(rename_all = "camelCase")]
Agent {
/// The originating agent id.
agent_id: String,
},
}
impl From<TurnSource> for TurnSourceDto {
fn from(source: TurnSource) -> Self {
match source {
TurnSource::Human => Self::Human,
TurnSource::Agent { agent_id } => Self::Agent {
agent_id: agent_id.to_string(),
},
}
}
}
/// One turn in the human transcript — **full text, never truncated**.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TurnViewDto {
/// Stable turn id (also the pagination anchor).
pub id: String,
/// Timestamp (epoch milliseconds).
pub at_ms: u64,
/// Turn nature (prompt, response, tool activity).
pub role: TurnRole,
/// Origin (human or delegating agent).
pub source: TurnSourceDto,
/// The turn's **complete** text (not truncated).
pub text: String,
/// Character length of the text.
pub text_len: usize,
}
impl From<TurnView> for TurnViewDto {
fn from(turn: TurnView) -> Self {
Self {
id: turn.id.to_string(),
at_ms: turn.at_ms,
role: turn.role,
source: turn.source.into(),
text: turn.text,
text_len: turn.text_len,
}
}
}
/// A page of the human transcript, oldest-to-newest.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TurnPageDto {
/// The page's turns, oldest to newest.
pub turns: Vec<TurnViewDto>,
/// Whether more turns exist beyond the page in the travel direction.
pub has_more: bool,
/// Last turn id of the page (anchor for the next request), or `None` if empty.
pub next_anchor: Option<String>,
}
impl From<TurnPage> for TurnPageDto {
fn from(page: TurnPage) -> Self {
Self {
turns: page.turns.into_iter().map(TurnViewDto::from).collect(),
has_more: page.has_more,
next_anchor: page.next_anchor.map(|id| id.to_string()),
}
}
}
#[cfg(test)]
mod ls6_pagination_tests {
//! LS6 — DTO de la lecture humaine paginée : parsing du curseur + mapping riche
//! `TurnPage → TurnPageDto` (texte complet non borné, source Human/Agent, `has_more`,
//! `next_anchor`). C'est le « round-trip d'une page » testable sans `State` Tauri.
use super::*;
use application::{TurnPage, TurnSource, TurnView};
fn tid(n: u128) -> domain::TurnId {
domain::TurnId::from_uuid(uuid::Uuid::from_u128(n))
}
#[test]
fn request_cursor_defaults_to_backward_and_parses_anchor() {
// Pas de direction ⇒ backward (la vue humaine ouvre sur la page la plus récente).
let req = ReadConversationPageRequestDto {
project_id: "p".into(),
conversation_id: "c".into(),
anchor: None,
direction: None,
limit: None,
};
let cur = req.cursor();
assert_eq!(cur.direction, PageDirection::Backward);
assert_eq!(cur.anchor, None);
// Forward (insensible à la casse) + ancre UUID valide.
let u = uuid::Uuid::from_u128(9);
let req = ReadConversationPageRequestDto {
project_id: "p".into(),
conversation_id: "c".into(),
anchor: Some(u.to_string()),
direction: Some("Forward".into()),
limit: Some(10),
};
let cur = req.cursor();
assert_eq!(cur.direction, PageDirection::Forward);
assert_eq!(cur.anchor, Some(domain::TurnId::from_uuid(u)));
// Ancre non-UUID ⇒ dégrade en None (page depuis un bout du fil), jamais d'erreur.
let req = ReadConversationPageRequestDto {
project_id: "p".into(),
conversation_id: "c".into(),
anchor: Some("not-a-uuid".into()),
direction: Some("backward".into()),
limit: None,
};
assert_eq!(req.cursor().anchor, None);
}
#[test]
fn turn_page_dto_round_trips_full_text_and_maps_sources() {
let big = "Z".repeat(40_000);
let page = TurnPage {
turns: vec![
TurnView {
id: tid(1),
at_ms: 1_700_000_000_000,
role: TurnRole::Prompt,
source: TurnSource::Human,
text: big.clone(),
text_len: big.chars().count(),
},
TurnView {
id: tid(2),
at_ms: 1_700_000_000_001,
role: TurnRole::Response,
source: TurnSource::Agent {
agent_id: domain::AgentId::from_uuid(uuid::Uuid::from_u128(7)),
},
text: "réponse".to_owned(),
text_len: 7,
},
],
has_more: true,
next_anchor: Some(tid(2)),
};
let dto = TurnPageDto::from(page);
// Texte complet préservé (non borné) + text_len.
assert_eq!(dto.turns[0].text.chars().count(), 40_000);
assert_eq!(dto.turns[0].text_len, 40_000);
// next_anchor stringifié, has_more porté.
assert!(dto.has_more);
assert_eq!(
dto.next_anchor.as_deref(),
Some(tid(2).to_string().as_str())
);
// Round-trip JSON (la « page » telle qu'envoyée au front) : camelCase + source.
let json = serde_json::to_value(&dto).unwrap();
assert_eq!(json["hasMore"], serde_json::json!(true));
assert_eq!(json["nextAnchor"], serde_json::json!(tid(2).to_string()));
assert_eq!(
json["turns"][0]["source"]["kind"],
serde_json::json!("human")
);
assert_eq!(
json["turns"][1]["source"]["kind"],
serde_json::json!("agent")
);
assert_eq!(
json["turns"][1]["source"]["agentId"],
serde_json::json!(uuid::Uuid::from_u128(7).to_string())
);
// Le texte intégral survit au passage JSON.
assert_eq!(
json["turns"][0]["text"].as_str().unwrap().chars().count(),
40_000
);
}
#[test]
fn empty_turn_page_dto_has_no_next_anchor() {
let dto = TurnPageDto::from(TurnPage {
turns: Vec::new(),
has_more: false,
next_anchor: None,
});
assert!(dto.turns.is_empty() && !dto.has_more && dto.next_anchor.is_none());
}
}