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>
180 lines
6.1 KiB
Rust
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)
|
|
}
|