feat(domain,application,infrastructure,backend,app-tauri,web-server,frontend): restaurer les projets ouverts au redémarrage dans l'ordre mémorisé — #303 (état versionné OpenProjectRestoreState persisté sous <app_data_dir>/open-projects.json via port dédié OpenProjectRestoreStore, distinct du registre projects.json et sans recycler workspace.json; sept use cases cadrés: read/record opened/record closed/save order/restore/retry/remove, mutations sérialisées par mutex applicatif contre les lost updates, tombstones conservés à leur position avec lastFailure et codes stables notFound/inaccessible/store/invalid; prévalidation root par metadata+listing sans interrompre les autres restaurations; activation centralisée BackendCore::activate_project réutilisée par ouverture Tauri/HTTP, création, restore et retry: reconciliation layouts/live-state/background tasks, watcher orchestrateur + serveur MCP, réparation run dirs Claude; cinq commandes exposées Tauri et HTTP avec contrats DTO identiques: restore_open_projects, retry_open_project_restore, remove_project_from_restore, save_open_project_restore_order, read_open_project_restore_state; frontend: DTO de reprise + ProjectGateway étendu, useProjects initialise par listProjects+restoreOpenProjects et reconstruit openTabs dans l'ordre opened sans notification quand tout réussit, notification unique singulier/pluriel avec Voir les détails/Réessayer, détail par projet avec cause française et Retirer de la reprise, retry réussi réinséré à la position mémorisée, onglet conservé si closeProject échoué; saveOpenProjectRestoreOrder exposé non appelé car l'UI ne permet pas encore de réordonnancer; QA verte reconfirmée par Git: rustup run stable cargo fmt --all -- --check, git diff --check, cargo test -p backend open_project_restore_tests 1/1 couvrant absence initiale/ordre/fermeture/inaccessible/retry/notFound/remove/watcher+MCP, cargo test -p web-server authorized_open_project_restore_commands_share_the_tauri_contract 1/1, cargo test -p domain 311+ suites vertes, cargo check -p application -p infrastructure -p backend -p app-tauri -p web-server OK, npm run typecheck OK, npm test ciblé 3 fichiers/37 tests OK, npm run build OK; réserves hors périmètre documentées au carnet: application tests/layout_usecases.rs 2 échecs fixtures plugin package NotFound, infrastructure 1 timing test cancellation process, npm test large 32 échecs web shell/LS7/tickets web — préexistants sans lien avec #303; restant: rebuild AppImage demandé après validation puis validation live fermeture/relance; fichiers tickets/296/*, tickets/299-303/*, counter/index, attachments/ et scripts/ hors périmètre; aucun push)

This commit is contained in:
2026-09-10 08:14:28 +02:00
parent 3911ce1f4d
commit 8df80ae67e
25 changed files with 1812 additions and 134 deletions

View File

@ -19,6 +19,7 @@ mod create;
mod files;
pub(crate) mod meta;
mod open;
mod restore;
pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput};
pub use context::{
@ -34,3 +35,9 @@ pub use meta::ProjectMeta;
pub use open::{
ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,
};
pub use restore::{
OpenProjectRestoreService, ReadOpenProjectRestoreState, RecordProjectClosedForRestore,
RecordProjectOpenedForRestore, RemoveProjectFromRestore, RestoreOpenProjectFailure,
RestoreOpenProjects, RestoreOpenProjectsOutput, RetryOpenProjectRestore,
SaveOpenProjectRestoreOrder,
};

View File

@ -0,0 +1,332 @@
//! Ordered open-project restore use cases (#303).
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use domain::ports::{
Clock, FileSystem, FsError, OpenProjectRestoreStore, ProjectStore, RemotePath, StoreError,
};
use domain::{
OpenProjectRestoreEntry, OpenProjectRestoreFailure, OpenProjectRestoreFailureCode,
OpenProjectRestoreState, Project, ProjectId,
};
use tokio::sync::Mutex;
use crate::AppError;
/// Shared serialized access to the restore document and its project dependencies.
pub struct OpenProjectRestoreService {
restore_store: Arc<dyn OpenProjectRestoreStore>,
projects: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
clock: Arc<dyn Clock>,
mutation: Mutex<()>,
}
impl OpenProjectRestoreService {
/// Builds the shared service.
#[must_use]
pub fn new(
restore_store: Arc<dyn OpenProjectRestoreStore>,
projects: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
clock: Arc<dyn Clock>,
) -> Self {
Self {
restore_store,
projects,
fs,
clock,
mutation: Mutex::new(()),
}
}
async fn load(&self) -> Result<OpenProjectRestoreState, AppError> {
self.restore_store
.load_open_project_restore_state()
.await
.map_err(AppError::from)
}
async fn save(
&self,
mut state: OpenProjectRestoreState,
) -> Result<OpenProjectRestoreState, AppError> {
state.normalize_positions();
state.updated_at = self.clock.now_millis();
self.restore_store
.save_open_project_restore_state(&state)
.await
.map_err(AppError::from)?;
Ok(state)
}
async fn restore_matching(
&self,
target: Option<ProjectId>,
failures_only: bool,
) -> Result<RestoreOpenProjectsOutput, AppError> {
let _guard = self.mutation.lock().await;
let mut state = self.load().await?;
state.normalize_positions();
if state.entries.is_empty() {
return Ok(RestoreOpenProjectsOutput {
opened: Vec::new(),
failures: Vec::new(),
});
}
let mut opened = Vec::new();
let mut failures = Vec::new();
for entry in &mut state.entries {
if target.is_some_and(|id| id != entry.project_id)
|| (failures_only && target.is_none() && entry.last_failure.is_none())
{
continue;
}
match self.resolve_entry(entry).await {
Ok(project) => {
entry.name = project.name.clone();
entry.root = project.root.as_str().to_owned();
entry.last_failure = None;
opened.push(project);
}
Err(failure) => {
entry.last_failure = Some(OpenProjectRestoreFailure {
code: failure.code,
message: failure.message.clone(),
});
failures.push(failure);
}
}
}
self.save(state).await?;
Ok(RestoreOpenProjectsOutput { opened, failures })
}
async fn resolve_entry(
&self,
entry: &OpenProjectRestoreEntry,
) -> Result<Project, RestoreOpenProjectFailure> {
let project = self
.projects
.load_project(entry.project_id)
.await
.map_err(|error| {
let code = match error {
StoreError::NotFound => OpenProjectRestoreFailureCode::NotFound,
StoreError::Invalid(_) | StoreError::Serialization(_) => {
OpenProjectRestoreFailureCode::Invalid
}
StoreError::Io(_) => OpenProjectRestoreFailureCode::Store,
};
RestoreOpenProjectFailure::from_entry(entry, code, error.to_string())
})?;
let root = RemotePath::new(project.root.as_str());
match self.fs.metadata(&root).await {
Ok(metadata) if metadata.is_dir => match self.fs.list(&root).await {
Ok(_) => Ok(project),
Err(error) => Err(RestoreOpenProjectFailure::from_entry(
entry,
OpenProjectRestoreFailureCode::Inaccessible,
error.to_string(),
)),
},
Ok(_) => Err(RestoreOpenProjectFailure::from_entry(
entry,
OpenProjectRestoreFailureCode::Inaccessible,
format!(
"project root is not an accessible directory: {}",
root.as_str()
),
)),
Err(error @ (FsError::NotFound(_) | FsError::PermissionDenied(_) | FsError::Io(_))) => {
Err(RestoreOpenProjectFailure::from_entry(
entry,
OpenProjectRestoreFailureCode::Inaccessible,
error.to_string(),
))
}
}
}
}
/// Structured restore failure returned to driving adapters.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RestoreOpenProjectFailure {
/// Project id.
pub project_id: ProjectId,
/// Last known name.
pub name: String,
/// Last known root.
pub root: String,
/// Stable restore position.
pub position: u32,
/// Stable failure code.
pub code: OpenProjectRestoreFailureCode,
/// Human-readable diagnostic.
pub message: String,
}
impl RestoreOpenProjectFailure {
fn from_entry(
entry: &OpenProjectRestoreEntry,
code: OpenProjectRestoreFailureCode,
message: String,
) -> Self {
Self {
project_id: entry.project_id,
name: entry.name.clone(),
root: entry.root.clone(),
position: entry.position,
code,
message,
}
}
}
/// Restore result in persisted order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RestoreOpenProjectsOutput {
/// Accessible projects, preserving their relative persisted order.
pub opened: Vec<Project>,
/// Failures, preserving their persisted positions.
pub failures: Vec<RestoreOpenProjectFailure>,
}
macro_rules! service_use_case {
($name:ident) => {
/// Ordered open-project restore use case.
pub struct $name {
service: Arc<OpenProjectRestoreService>,
}
impl $name {
/// Builds the use case from the shared serialized service.
#[must_use]
pub fn new(service: Arc<OpenProjectRestoreService>) -> Self {
Self { service }
}
}
};
}
service_use_case!(ReadOpenProjectRestoreState);
service_use_case!(RecordProjectOpenedForRestore);
service_use_case!(RecordProjectClosedForRestore);
service_use_case!(SaveOpenProjectRestoreOrder);
service_use_case!(RestoreOpenProjects);
service_use_case!(RetryOpenProjectRestore);
service_use_case!(RemoveProjectFromRestore);
impl ReadOpenProjectRestoreState {
/// Loads the current state.
pub async fn execute(&self) -> Result<OpenProjectRestoreState, AppError> {
let _guard = self.service.mutation.lock().await;
self.service.load().await
}
}
impl RecordProjectOpenedForRestore {
/// Adds a project at the end or refreshes its retained entry in place.
pub async fn execute(&self, project: Project) -> Result<OpenProjectRestoreState, AppError> {
let _guard = self.service.mutation.lock().await;
let mut state = self.service.load().await?;
if let Some(entry) = state
.entries
.iter_mut()
.find(|entry| entry.project_id == project.id)
{
entry.name = project.name;
entry.root = project.root.as_str().to_owned();
entry.last_failure = None;
} else {
state.entries.push(OpenProjectRestoreEntry {
project_id: project.id,
name: project.name,
root: project.root.as_str().to_owned(),
position: state.entries.len() as u32,
last_failure: None,
});
}
self.service.save(state).await
}
}
impl RecordProjectClosedForRestore {
/// Removes an explicitly closed project.
pub async fn execute(
&self,
project_id: ProjectId,
) -> Result<OpenProjectRestoreState, AppError> {
let _guard = self.service.mutation.lock().await;
let mut state = self.service.load().await?;
state.entries.retain(|entry| entry.project_id != project_id);
self.service.save(state).await
}
}
impl RemoveProjectFromRestore {
/// Removes one retained failure/tombstone from future restoration.
pub async fn execute(
&self,
project_id: ProjectId,
) -> Result<OpenProjectRestoreState, AppError> {
RecordProjectClosedForRestore::new(Arc::clone(&self.service))
.execute(project_id)
.await
}
}
impl SaveOpenProjectRestoreOrder {
/// Reorders known entries and appends omitted retained entries in their prior order.
pub async fn execute(
&self,
ordered_project_ids: Vec<ProjectId>,
) -> Result<OpenProjectRestoreState, AppError> {
let _guard = self.service.mutation.lock().await;
let mut state = self.service.load().await?;
let mut seen = HashSet::new();
if ordered_project_ids.iter().any(|id| !seen.insert(*id)) {
return Err(AppError::Invalid(
"ordered project ids must be unique".to_owned(),
));
}
let mut by_id = state
.entries
.drain(..)
.map(|entry| (entry.project_id, entry))
.collect::<HashMap<_, _>>();
let mut entries = Vec::new();
for project_id in ordered_project_ids {
let entry = by_id.remove(&project_id).ok_or_else(|| {
AppError::NotFound(format!("open-project restore entry {project_id}"))
})?;
entries.push(entry);
}
let mut retained = by_id.into_values().collect::<Vec<_>>();
retained.sort_by_key(|entry| entry.position);
entries.extend(retained);
for (position, entry) in entries.iter_mut().enumerate() {
entry.position = position as u32;
}
state.entries = entries;
self.service.save(state).await
}
}
impl RestoreOpenProjects {
/// Restores every retained entry without aborting on per-project failures.
pub async fn execute(&self) -> Result<RestoreOpenProjectsOutput, AppError> {
self.service.restore_matching(None, false).await
}
}
impl RetryOpenProjectRestore {
/// Retries one requested entry, or every entry carrying a prior failure.
pub async fn execute(
&self,
project_id: Option<ProjectId>,
) -> Result<RestoreOpenProjectsOutput, AppError> {
self.service.restore_matching(project_id, true).await
}
}