fix(layout): auto-réparer l'onglet actif

stale

Le backend renvoie l'id actif autoritaire et le
    frontend l'adopte pour éviter de rejouer un layout
    disparu après overwrite externe.

Co-Authored-By: Claude Opus 4.8
    <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:23:40 +02:00
parent 137620daa3
commit e916ecd95e
14 changed files with 372 additions and 51 deletions

View File

@ -19,12 +19,11 @@ use application::{
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
ReconcileLiveStateInput,
RenameLayoutInput, ResolveAgentPermissionsInput, ResolveMemoryLinksInput,
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput,
UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
ReconcileLiveStateInput, RenameLayoutInput, ResolveAgentPermissionsInput,
ResolveMemoryLinksInput, RotateConversationLogInput, SetActiveLayoutInput,
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
};
use domain::ports::PtyHandle;
@ -49,13 +48,13 @@ use crate::dto::{
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,
SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto,
SkillListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto,
UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto,
UpdateTemplateRequestDto, WriteTerminalRequestDto,
};
use crate::pty::{PtyBridge, PtyChunk};
use crate::state::AppState;
@ -678,14 +677,16 @@ pub async fn delete_layout(
/// `set_active_layout` — switch the active named layout of a project.
///
/// Self-healing: a stale `layout_id` does not error; the active layout is left
/// unchanged and the actually-active id is returned (authoritative).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed id, `NOT_FOUND` if the
/// project or layout is unknown).
/// Returns an [`ErrorDto`] (`INVALID` for malformed id) or a persistence error.
#[tauri::command]
pub async fn set_active_layout(
request: SetActiveLayoutRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
) -> Result<SetActiveLayoutResultDto, ErrorDto> {
let project_id = parse_project_id(&request.project_id)?;
let layout_id = parse_layout_id(&request.layout_id)?;
state
@ -695,6 +696,7 @@ pub async fn set_active_layout(
layout_id,
})
.await
.map(SetActiveLayoutResultDto::from)
.map_err(ErrorDto::from)
}

View File

@ -390,7 +390,7 @@ pub fn parse_session_id(raw: &str) -> Result<SessionId, ErrorDto> {
use application::{
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput,
LoadLayoutOutput, MutateLayoutOutput,
LoadLayoutOutput, MutateLayoutOutput, SetActiveLayoutOutput,
};
use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId};
@ -700,6 +700,23 @@ pub struct DeleteLayoutRequestDto {
pub layout_id: String,
}
/// Response DTO for `set_active_layout`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetActiveLayoutResultDto {
/// The layout actually activated — equals the requested id when valid, else
/// the unchanged current active id (self-healing fallback). Authoritative.
pub active_id: String,
}
impl From<SetActiveLayoutOutput> for SetActiveLayoutResultDto {
fn from(out: SetActiveLayoutOutput) -> Self {
Self {
active_id: out.active_id.to_string(),
}
}
}
/// Request DTO for `set_active_layout`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]

View File

@ -297,6 +297,15 @@ pub struct SetActiveLayoutInput {
pub layout_id: LayoutId,
}
/// Output of [`SetActiveLayout::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetActiveLayoutOutput {
/// The id of the layout that was **actually** made active. Equals the
/// requested id when it exists; otherwise the unchanged current active id
/// (self-healing fallback). Authoritative for the frontend (invariant I4).
pub active_id: LayoutId,
}
/// Switches the active layout of a project.
pub struct SetActiveLayout {
store: Arc<dyn ProjectStore>,
@ -317,20 +326,30 @@ impl SetActiveLayout {
/// Sets the active layout.
///
/// A stale requested id (e.g. an `activeId` left over after git overwrote
/// `layouts.json`) must **not** freeze the workspace: instead of hard-
/// erroring, it degrades silently to the current active layout (invariant
/// I3). The returned [`SetActiveLayoutOutput::active_id`] is the id that was
/// actually activated and is authoritative for the frontend (invariant I4).
///
/// # Errors
/// [`AppError::NotFound`] if the project or layout is unknown.
pub async fn execute(&self, input: SetActiveLayoutInput) -> Result<(), AppError> {
/// - [`AppError::FileSystem`] on persistence failure,
/// - [`AppError::Store`] on registry I/O failure.
pub async fn execute(
&self,
input: SetActiveLayoutInput,
) -> Result<SetActiveLayoutOutput, AppError> {
let project = self.store.load_project(input.project_id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
if doc.find(input.layout_id).is_none() {
return Err(AppError::NotFound(format!("layout {}", input.layout_id)));
}
doc.active_id = input.layout_id;
// Self-heal: keep the requested id when valid, else fall back to the
// (always-valid, I2) current active id rather than erroring.
let active_id = doc.resolve_existing_id(Some(input.layout_id));
doc.active_id = active_id;
persist_doc(self.fs.as_ref(), &project, &doc).await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project_id,
});
Ok(())
Ok(SetActiveLayoutOutput { active_id })
}
}

View File

@ -29,7 +29,7 @@ mod usecases;
pub use management::{
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput,
};
pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOutput};
pub use snapshot::{

View File

@ -77,6 +77,22 @@ impl LayoutsDoc {
id.unwrap_or(self.active_id)
}
/// Resolves a requested view id to a **valid** layout id, never failing.
///
/// Returns `id` when it is present in `layouts`; otherwise falls back to
/// `active_id` (guaranteed valid by invariant I2 / [`Self::is_valid`]).
/// A `None` request (the current view) also resolves to `active_id`. This
/// is the self-healing path: a stale `active_id` written by an external tool
/// (e.g. git overwriting `layouts.json`) degrades silently instead of
/// hard-erroring and freezing the workspace (invariant I3).
#[must_use]
pub fn resolve_existing_id(&self, id: Option<LayoutId>) -> LayoutId {
match id {
Some(id) if self.find(id).is_some() => id,
_ => self.active_id,
}
}
/// Finds a layout by id.
#[must_use]
pub fn find(&self, id: LayoutId) -> Option<&NamedLayout> {
@ -177,3 +193,55 @@ pub async fn resolve_doc(fs: &dyn FileSystem, project: &Project) -> Result<Layou
persist_doc(fs, project, &doc).await?;
Ok(doc)
}
#[cfg(test)]
mod tests {
use super::*;
/// Builds a doc with two layouts; the first one is active.
fn two_layout_doc() -> (LayoutsDoc, LayoutId, LayoutId) {
let active = LayoutId::new_random();
let other = LayoutId::new_random();
let doc = LayoutsDoc {
version: LAYOUTS_VERSION,
active_id: active,
layouts: vec![
NamedLayout {
id: active,
name: "Default".to_owned(),
kind: LayoutKind::Terminal,
tree: default_tree(),
},
NamedLayout {
id: other,
name: "Second".to_owned(),
kind: LayoutKind::Terminal,
tree: default_tree(),
},
],
};
(doc, active, other)
}
#[test]
fn resolve_existing_id_returns_present_id() {
let (doc, _active, other) = two_layout_doc();
// A present, non-active id is returned verbatim.
assert_eq!(doc.resolve_existing_id(Some(other)), other);
}
#[test]
fn resolve_existing_id_falls_back_to_active_when_absent() {
let (doc, active, _other) = two_layout_doc();
// A stale id absent from `layouts` degrades to the valid active id.
let stale = LayoutId::new_random();
assert_eq!(doc.resolve_existing_id(Some(stale)), active);
}
#[test]
fn resolve_existing_id_none_resolves_to_active() {
let (doc, active, _other) = two_layout_doc();
// The "current view" request resolves to the active id.
assert_eq!(doc.resolve_existing_id(None), active);
}
}

View File

@ -169,7 +169,10 @@ impl LoadLayout {
pub async fn execute(&self, input: LoadLayoutInput) -> Result<LoadLayoutOutput, AppError> {
let project = self.store.load_project(input.project_id).await?;
let doc = resolve_doc(self.fs.as_ref(), &project).await?;
let id = doc.resolve_id(input.layout_id);
// Self-healing (I3): a stale requested id degrades to the valid active
// id instead of hard-erroring. `find` is now infallible in practice;
// the `ok_or` is kept as a purely defensive guard.
let id = doc.resolve_existing_id(input.layout_id);
let named = doc
.find(id)
.ok_or_else(|| AppError::NotFound(format!("layout {id}")))?;
@ -229,7 +232,10 @@ impl MutateLayout {
pub async fn execute(&self, input: MutateLayoutInput) -> Result<MutateLayoutOutput, AppError> {
let project = self.store.load_project(input.project_id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
let id = doc.resolve_id(input.layout_id);
// Self-healing (I3): a stale requested id degrades to the valid active
// id instead of hard-erroring. `find_mut` is now infallible in
// practice; the `ok_or` is kept as a purely defensive guard.
let id = doc.resolve_existing_id(input.layout_id);
let named = doc
.find_mut(id)

View File

@ -31,8 +31,7 @@ pub mod window;
pub mod workstate;
pub use agent::{
drain_with_readiness, drain_with_readiness_outcome,
reference_profile_id, reference_profiles,
drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles,
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
@ -73,8 +72,8 @@ pub use layout::{
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, ReconcileLayouts,
ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout, RenameLayoutInput,
SetActiveLayout, SetActiveLayoutInput, SnapshotRunningAgents, SnapshotRunningAgentsInput,
SnapshotRunningAgentsOutput, LAYOUTS_FILE,
SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput, SnapshotRunningAgents,
SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
};
pub use memory::{
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,

View File

@ -338,21 +338,24 @@ async fn load_tolerates_corrupt_json_with_default() {
}
#[tokio::test]
async fn load_unknown_layout_id_is_not_found() {
async fn load_unknown_layout_id_self_heals_to_active() {
let store = FakeStore::default();
let fs = FakeFs::default();
let id = register_project(&store, pid(5)).await;
seed_layouts(&fs, lid(1), &single_leaf(nid(1)));
// A stale requested id (e.g. left over after git overwrote layouts.json)
// must NOT hard-error and freeze the workspace; it degrades silently to the
// valid active layout (invariant I3), whose id is authoritative (I4).
let load = LoadLayout::new(Arc::new(store), Arc::new(fs));
let err = load
let out = load
.execute(LoadLayoutInput {
project_id: id,
layout_id: Some(lid(999)),
})
.await
.expect_err("unknown layout id rejected");
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
.expect("stale layout id must self-heal, not error");
assert_eq!(out.layout_id, lid(1));
}
#[tokio::test]
@ -937,13 +940,15 @@ async fn set_active_layout_switches_and_load_follows() {
.unwrap();
// Switch back to the Default layout.
SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
let set = SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
.execute(SetActiveLayoutInput {
project_id: pid(35),
layout_id: lid(1),
})
.await
.unwrap();
// Output echoes the actually-activated id (I4).
assert_eq!(set.active_id, lid(1));
let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs))
.execute(LoadLayoutInput {
@ -955,3 +960,31 @@ async fn set_active_layout_switches_and_load_follows() {
assert_eq!(loaded.layout_id, lid(1));
assert_ne!(loaded.layout_id, created.layout_id);
}
#[tokio::test]
async fn set_active_layout_stale_id_self_heals_to_current_active() {
// Seeded env: a single "Default" layout, active = lid(1).
let (store, fs, bus) = mgmt_env(pid(36)).await;
// Targeting a stale id (e.g. an activeId left over after git overwrote
// layouts.json) must NOT error and must NOT freeze the workspace (I3):
// it degrades to the current active id and returns it (I4).
let out = SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
.execute(SetActiveLayoutInput {
project_id: pid(36),
layout_id: lid(999),
})
.await
.expect("stale set_active must self-heal, not error");
assert_eq!(out.active_id, lid(1));
// And a subsequent load of the current view resolves to that valid id.
let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs))
.execute(LoadLayoutInput {
project_id: pid(36),
layout_id: None,
})
.await
.unwrap();
assert_eq!(loaded.layout_id, lid(1));
}