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:
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user