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:
@ -17,12 +17,13 @@ use application::{
|
||||
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
|
||||
LaunchAgentInput, ListAgentsInput, ListLayoutsInput, ListMemoriesInput,
|
||||
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
|
||||
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadMemoryIndexInput,
|
||||
ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, RenameLayoutInput,
|
||||
ResolveAgentPermissionsInput, ResolveMemoryLinksInput, SetActiveLayoutInput,
|
||||
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
||||
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
|
||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
||||
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
|
||||
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
||||
RenameLayoutInput, ResolveAgentPermissionsInput, ResolveMemoryLinksInput,
|
||||
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
|
||||
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
||||
UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput,
|
||||
UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
||||
};
|
||||
use domain::ports::PtyHandle;
|
||||
|
||||
@ -43,13 +44,14 @@ use crate::dto::{
|
||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||
OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
||||
ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReattachChatDto,
|
||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
|
||||
SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto,
|
||||
SkillListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
||||
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||
TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||
ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
||||
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||
SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, SkillListDto,
|
||||
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
|
||||
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
||||
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||
WriteTerminalRequestDto,
|
||||
@ -989,6 +991,39 @@ pub async fn get_project_work_state(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `read_conversation_page` — human, paginated read of a conversation's **full**
|
||||
/// transcript (lot LS6). Archive-aware (segments + active), text never truncated.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed project/conversation id,
|
||||
/// `NOT_FOUND` if the project is unknown, `STORE` on log I/O failure).
|
||||
#[tauri::command]
|
||||
pub async fn read_conversation_page(
|
||||
request: ReadConversationPageRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<TurnPageDto, ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let conversation = uuid::Uuid::parse_str(&request.conversation_id)
|
||||
.map(domain::ConversationId::from_uuid)
|
||||
.map_err(|_| ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: format!("invalid conversation id: {}", request.conversation_id),
|
||||
})?;
|
||||
let cursor = request.cursor();
|
||||
let limit = request.limit.unwrap_or(0);
|
||||
state
|
||||
.read_conversation_page
|
||||
.execute(ReadConversationPageInput {
|
||||
project_root: project.root,
|
||||
conversation,
|
||||
cursor,
|
||||
limit,
|
||||
})
|
||||
.await
|
||||
.map(TurnPageDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `list_live_agents` — list every agent that currently owns a live session
|
||||
/// (raw PTY **or** structured/chat) and the cell hosting each, so the UI can
|
||||
/// disable an agent already running in another cell (the "one live session per
|
||||
@ -1222,6 +1257,9 @@ pub async fn launch_agent(
|
||||
// agent so an auto-resume (which only carries agent/node/conversation) can recompose a
|
||||
// full `LaunchAgentInput`. Cloned here — `project` is moved into the launch below.
|
||||
let resume_project = project.clone();
|
||||
// Lot LS6 — project root captured before `project` moves, for the off-hot-path log
|
||||
// rotation triggered at thread (re)open below.
|
||||
let rotation_root = project.root.clone();
|
||||
|
||||
let output = state
|
||||
.launch_agent
|
||||
@ -1250,6 +1288,28 @@ pub async fn launch_agent(
|
||||
);
|
||||
}
|
||||
|
||||
// Lot LS6 — rotation **best-effort** du log, déclenchée à la (re)ouverture du fil et
|
||||
// **hors chemin chaud** : détachée (`tokio::spawn`) pour n'ajouter aucune latence au
|
||||
// launch, erreurs **avalées** (la rotation ne doit jamais casser une reprise). Un
|
||||
// `append` ne déclenche JAMAIS la rotation. Skippée si la cellule ne porte pas une
|
||||
// `conversation_id` UUID (rien à roter).
|
||||
if let Some(conversation) = request
|
||||
.conversation_id
|
||||
.as_deref()
|
||||
.and_then(|raw| uuid::Uuid::parse_str(raw).ok())
|
||||
.map(domain::ConversationId::from_uuid)
|
||||
{
|
||||
let rotate = std::sync::Arc::clone(&state.rotate_conversation_log);
|
||||
tokio::spawn(async move {
|
||||
let _ = rotate
|
||||
.execute(RotateConversationLogInput {
|
||||
project_root: rotation_root,
|
||||
conversation,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
let session_id = output.session.id;
|
||||
// Host cell of the freshly (re)launched session — the pivot the level-2 tap reports
|
||||
// to the service alongside the agent.
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,6 +164,7 @@ pub fn run() {
|
||||
commands::create_agent,
|
||||
commands::list_agents,
|
||||
commands::get_project_work_state,
|
||||
commands::read_conversation_page,
|
||||
commands::list_live_agents,
|
||||
commands::attach_live_agent,
|
||||
commands::stop_live_agent,
|
||||
|
||||
@ -26,14 +26,15 @@ use application::{
|
||||
LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
||||
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
|
||||
ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory,
|
||||
ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
|
||||
ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile,
|
||||
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, StructuredSessions,
|
||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
|
||||
UpdateAgentContext, UpdateAgentPermissions, UpdateLiveState, UpdateMemory,
|
||||
UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory,
|
||||
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
ReadContext, ReadConversationPage, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill,
|
||||
RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||
RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
|
||||
RotateConversationLog, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
||||
SnapshotRunningAgents, StopLiveAgent, StructuredSessions, SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
|
||||
UpdateAgentPermissions, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||
UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory, WriteToTerminal,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
|
||||
@ -93,6 +94,24 @@ impl RecordTurnProvider for AppRecordTurnProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Implémente [`ConversationArchiveProvider`](application::ConversationArchiveProvider)
|
||||
/// (lot LS6) en matérialisant un [`FsConversationLog`] (qui implémente aussi
|
||||
/// [`domain::ConversationArchive`]) ciblant le **project root** courant.
|
||||
///
|
||||
/// Même raison d'être que [`AppRecordTurnProvider`] : les use cases d'archivage/pagination
|
||||
/// sont uniques pour tous les projets, alors que les logs sont **par project root**. On
|
||||
/// construit donc un archive frais par appel, ciblant le bon dossier. Sans état.
|
||||
struct AppConversationArchiveProvider;
|
||||
|
||||
impl application::ConversationArchiveProvider for AppConversationArchiveProvider {
|
||||
fn conversation_archive_for(
|
||||
&self,
|
||||
root: &domain::project::ProjectPath,
|
||||
) -> Option<Arc<dyn domain::ConversationArchive>> {
|
||||
Some(Arc::new(FsConversationLog::new(root)) as Arc<dyn domain::ConversationArchive>)
|
||||
}
|
||||
}
|
||||
|
||||
/// Implémente [`HandoffProvider`](application::HandoffProvider) (lot P7) en
|
||||
/// matérialisant un [`FsHandoffStore`] ciblant le **project root** du lancement en
|
||||
/// cours.
|
||||
@ -414,6 +433,10 @@ pub struct AppState {
|
||||
pub list_resumable_agents: Arc<ListResumableAgents>,
|
||||
/// Read-only live/busy state for the project's manifest agents.
|
||||
pub get_project_work_state: Arc<GetProjectWorkState>,
|
||||
/// Human paginated read of a conversation's full transcript (lot LS6).
|
||||
pub read_conversation_page: Arc<ReadConversationPage>,
|
||||
/// Best-effort log rotation, triggered off the hot path at thread resume/open (lot LS6).
|
||||
pub rotate_conversation_log: Arc<RotateConversationLog>,
|
||||
/// Rebinds an already-running agent's live session to a visible cell (Lot D).
|
||||
pub attach_live_agent: Arc<AttachLiveAgent>,
|
||||
/// Tears down an already-running agent's live session by agent id (Lot D).
|
||||
@ -1143,6 +1166,17 @@ impl AppState {
|
||||
as Arc<dyn application::ConversationLogProvider>,
|
||||
),
|
||||
);
|
||||
// Lot LS6 — rotation (hors chemin chaud) + lecture humaine paginée. Tous deux
|
||||
// composent le provider d'archive par root ; la rotation lit aussi le handoff
|
||||
// (plancher `up_to`, INV-LS6). Aucune persistance déclenchée par un `append`.
|
||||
let archive_provider = Arc::new(AppConversationArchiveProvider)
|
||||
as Arc<dyn application::ConversationArchiveProvider>;
|
||||
let read_conversation_page =
|
||||
Arc::new(ReadConversationPage::new(Arc::clone(&archive_provider)));
|
||||
let rotate_conversation_log = Arc::new(RotateConversationLog::new(
|
||||
Arc::clone(&archive_provider),
|
||||
Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>,
|
||||
));
|
||||
// Lot D — actions contrôlées agent-level sur les sessions vivantes : attach
|
||||
// (rebind de la cellule-vue, zéro spawn) et stop (kill PTY via la primitive
|
||||
// `CloseTerminal` existante / shutdown structuré). Aucune création de session.
|
||||
@ -1343,6 +1377,8 @@ impl AppState {
|
||||
change_agent_profile,
|
||||
list_resumable_agents,
|
||||
get_project_work_state,
|
||||
read_conversation_page,
|
||||
rotate_conversation_log,
|
||||
attach_live_agent,
|
||||
stop_live_agent,
|
||||
inspect_conversation,
|
||||
|
||||
Reference in New Issue
Block a user