Files
IdeA/crates/application/src/layout/store.rs
Blomios e583b2b49f feat(persistence): P8a — cohérence de clé conversation (id de paire stable)
Corrige l'incohérence P6/P7 : la cellule et la persistance partagent désormais
le MÊME id de paire IdeA, déterministe et stable au redémarrage — la clé sous
laquelle P6b sauve log/handoff == celle sous laquelle P7 les charge.

- domaine : ConversationId::for_pair(a,b) pur/déterministe (User↔Agent = uuid
  agent ; Agent↔Agent = XOR commutatif) ; LeafCell gagne engine_session_id
  (cache resumable moteur, additif serde default)
- infrastructure : InMemoryConversationRegistry::resolve utilise for_pair au
  lieu de new_random() → id recalculable sans état, identique après restart
- application : launch_structured persiste l'id de paire sur la cellule (et l'id
  moteur sur engine_session_id) ; cellule neuve ⇒ dérive for_pair(User,agent) ;
  resolve_conversation repli factorisé sur for_pair ; withers layout clone-and-
  mutate (préservent les champs additifs)
- app-tauri : engine_session_id propagé dans les DTO

Suites domain/infra/application/app-tauri vertes (assertion structured_launch_d3
recodée sur le contrat). Test déterminisme/restart dédié à ajouter au retour de
quota agent (binôme Test interrompu par limite de session).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:10:50 +02:00

180 lines
6.1 KiB
Rust

//! Persistence of a project's **named terminal layouts** (#4).
//!
//! A project no longer has a single layout: `.ideai/layouts.json` holds a
//! collection of named [`LayoutTree`]s plus which one is active:
//!
//! ```json
//! { "version": 1, "activeId": "…", "layouts": [ { "id": "…", "name": "Default", "tree": { … } } ] }
//! ```
//!
//! Migration: a project that still has the legacy single `.ideai/layout.json`
//! (pre-#4) is upgraded transparently — its tree becomes the first "Default"
//! layout. A missing/corrupt store self-heals to one default layout (same
//! self-healing contract as the original L4 single-file resolver).
use serde::{Deserialize, Serialize};
use domain::ports::{FileSystem, RemotePath};
use domain::{LayoutId, LayoutTree, LeafCell, NodeId, Project};
use crate::error::AppError;
use crate::project::meta::{from_json_bytes, join_root, to_json_bytes, IDEAI_DIR};
/// File name of the named-layouts store inside a project's `.ideai/`.
pub const LAYOUTS_FILE: &str = "layouts.json";
/// Legacy single-layout file (pre-#4), migrated on first read if present.
const LEGACY_LAYOUT_FILE: &str = "layout.json";
/// Current schema version of `layouts.json`.
const LAYOUTS_VERSION: u32 = 1;
/// Name given to the layout created by default / migrated from the legacy file.
const DEFAULT_LAYOUT_NAME: &str = "Default";
/// Discriminates the kind of content a named layout holds.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LayoutKind {
/// A terminal-grid layout (the original kind).
#[default]
Terminal,
/// A Git-graph visualisation layout.
GitGraph,
}
/// One named layout: a stable id, a display name and its terminal grid tree.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NamedLayout {
/// Stable identifier.
pub id: LayoutId,
/// Display name (shown in the layouts tab bar).
pub name: String,
/// The kind of this layout (terminal grid or git-graph).
#[serde(default)]
pub kind: LayoutKind,
/// The terminal grid for this layout (present but ignored for GitGraph).
pub tree: LayoutTree,
}
/// On-disk shape of `.ideai/layouts.json`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LayoutsDoc {
/// Schema version.
pub version: u32,
/// The currently active layout (always one of `layouts`).
pub active_id: LayoutId,
/// All named layouts (at least one).
pub layouts: Vec<NamedLayout>,
}
impl LayoutsDoc {
/// The active layout id, or the explicit one when provided.
#[must_use]
pub fn resolve_id(&self, id: Option<LayoutId>) -> LayoutId {
id.unwrap_or(self.active_id)
}
/// Finds a layout by id.
#[must_use]
pub fn find(&self, id: LayoutId) -> Option<&NamedLayout> {
self.layouts.iter().find(|l| l.id == id)
}
/// Mutable access to a layout by id.
pub fn find_mut(&mut self, id: LayoutId) -> Option<&mut NamedLayout> {
self.layouts.iter_mut().find(|l| l.id == id)
}
/// Structural validity: non-empty, `active_id` present, every tree valid.
fn is_valid(&self) -> bool {
!self.layouts.is_empty()
&& self.find(self.active_id).is_some()
&& self.layouts.iter().all(|l| l.tree.validate().is_ok())
}
}
/// The default single-cell layout tree (one empty leaf).
#[must_use]
pub fn default_tree() -> LayoutTree {
LayoutTree::single(LeafCell::new(NodeId::new_random()))
}
/// Builds a fresh doc holding one layout (`tree`) made active.
fn doc_with(id: LayoutId, name: &str, tree: LayoutTree) -> LayoutsDoc {
LayoutsDoc {
version: LAYOUTS_VERSION,
active_id: id,
layouts: vec![NamedLayout {
id,
name: name.to_owned(),
kind: LayoutKind::Terminal,
tree,
}],
}
}
fn layouts_path(project: &Project) -> RemotePath {
RemotePath::new(join_root(
&project.root,
&format!("{IDEAI_DIR}/{LAYOUTS_FILE}"),
))
}
fn legacy_path(project: &Project) -> RemotePath {
RemotePath::new(join_root(
&project.root,
&format!("{IDEAI_DIR}/{LEGACY_LAYOUT_FILE}"),
))
}
/// Reads the legacy single layout tree if present and valid (for migration).
async fn read_legacy_tree(fs: &dyn FileSystem, project: &Project) -> Option<LayoutTree> {
let bytes = fs.read(&legacy_path(project)).await.ok()?;
let tree = from_json_bytes::<LayoutTree>(&bytes).ok()?;
tree.validate().is_ok().then_some(tree)
}
/// Writes the doc to `.ideai/layouts.json` (creating `.ideai/` if needed).
pub async fn persist_doc(
fs: &dyn FileSystem,
project: &Project,
doc: &LayoutsDoc,
) -> Result<(), AppError> {
let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR));
fs.create_dir_all(&ideai_dir).await?;
fs.write(&layouts_path(project), &to_json_bytes(doc)?)
.await?;
Ok(())
}
/// Resolves the project's layouts doc, with idempotent initialisation /
/// migration / self-healing (mirrors the L4 single-file resolver contract).
///
/// - **Present & valid**: returned as-is (no write).
/// - **Absent**: migrated from the legacy `layout.json` if present, else seeded
/// with one empty "Default" layout — and **persisted** (write-through, so ids
/// are stable from the first load).
/// - **Present but corrupt/invalid**: overwritten with a fresh default.
pub async fn resolve_doc(fs: &dyn FileSystem, project: &Project) -> Result<LayoutsDoc, AppError> {
if let Ok(bytes) = fs.read(&layouts_path(project)).await {
if let Ok(doc) = from_json_bytes::<LayoutsDoc>(&bytes) {
if doc.is_valid() {
return Ok(doc);
}
}
// Present-but-invalid: fall through and re-seed.
}
// First run for this project (or self-heal): migrate the legacy tree if any.
let tree = match read_legacy_tree(fs, project).await {
Some(tree) => tree,
None => default_tree(),
};
let doc = doc_with(LayoutId::new_random(), DEFAULT_LAYOUT_NAME, tree);
persist_doc(fs, project, &doc).await?;
Ok(doc)
}