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:
@ -204,10 +204,13 @@ pub use plugin::{
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
CreateProjectInput, CreateProjectOutput, ListProjectFiles, ListProjectFilesInput, ListProjects,
|
||||
ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput, ProjectFileEntry,
|
||||
ProjectFileKind, ProjectFileListing, ProjectMeta, ReadProjectContext, ReadProjectContextInput,
|
||||
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
|
||||
PROJECT_CONTEXT_FILE,
|
||||
ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,
|
||||
OpenProjectRestoreService, ProjectFileEntry, ProjectFileKind, ProjectFileListing, ProjectMeta,
|
||||
ReadOpenProjectRestoreState, ReadProjectContext, ReadProjectContextInput,
|
||||
ReadProjectContextOutput, RecordProjectClosedForRestore, RecordProjectOpenedForRestore,
|
||||
RemoveProjectFromRestore, RestoreOpenProjectFailure, RestoreOpenProjects,
|
||||
RestoreOpenProjectsOutput, RetryOpenProjectRestore, SaveOpenProjectRestoreOrder,
|
||||
UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
|
||||
};
|
||||
pub use project_plugin::{
|
||||
plugin_skill_ref, plugin_tool_ref, AssignmentMutation, ListProjectPluginSettings,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
332
crates/application/src/project/restore.rs
Normal file
332
crates/application/src/project/restore.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user