diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index ec7cae0d..2bb818ec 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -22,16 +22,15 @@ use application::{ GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions, - LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput, + LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput, - ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, - ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, - ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, - RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, - SetAgentMcpToolAllowedInput, SlashCommandEffect, SnapshotRunningAgentsInput, - StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, - UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput, - UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, + ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput, + RenameLayoutInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, + ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SaveChatDraftInput, + SetActiveLayoutInput, SetAgentMcpToolAllowedInput, SlashCommandEffect, + SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, + UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput, + UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectOrchestratorInput, UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, @@ -65,20 +64,22 @@ use crate::dto::{ LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, ManagedTempGcResponseDto, ManagedTempHandleRequestDto, ManagedTempLeaseDto, ManagedTempLeaseResponseDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto, - OpenCodeProviderListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, - ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto, - ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto, - ProjectWorkStateDto, ReadAgentContextResponseDto, ReadChatDraftResponseDto, - ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, - RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, - ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, - ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, - ResumableAgentListDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto, - SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, - SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto, - SkillDto, SkillListDto, SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, - StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, - TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, + OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto, + PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, + ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, + ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, + ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, + RecallMemoryRequestDto, RemoveOpenProjectRestoreEntryRequestDto, RenameLayoutRequestDto, + ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, + ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto, + ResolvedAgentSystemPermissionsDto, RestoreOpenProjectsOutputDto, ResumableAgentListDto, + RetryOpenProjectRestoreRequestDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto, + SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, + SaveOpenProjectRestoreOrderRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, + SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto, + SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, + SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, + TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, @@ -438,9 +439,12 @@ pub async fn create_project( .execute(request.into()) .await .map_err(ErrorDto::from)?; - // Start tailing this project's `.ideai/requests/` tree so an orchestrator - // agent can delegate agent/skill creation to IdeA (§14.3). - state.ensure_orchestrator_watch(&output.project); + state + .record_project_opened_for_restore + .execute(output.project.clone()) + .await + .map_err(ErrorDto::from)?; + state.activate_project(&output.project).await; Ok(ProjectDto::from(output)) } @@ -467,36 +471,9 @@ pub(crate) async fn open_project_for_adapter( ) -> Result { let id = parse_project_id(&project_id)?; let output = state - .open_project - .execute(OpenProjectInput { project_id: id }) + .open_project_and_activate(id) .await .map_err(ErrorDto::from)?; - // R0c (§3.4 « Trou C ») : dé-doublonne les `layouts.json` portant plusieurs - // feuilles sur le même agent AVANT toute reprise (`list_resumable_agents` - // relit la version persistée), de sorte qu'on ne propose / ne relance qu'une - // session par agent. Idempotent (no-op sans doublon) et best-effort : un échec - // ne doit pas bloquer l'ouverture. - let _ = state - .reconcile_layouts - .execute(ReconcileLayoutsInput { project_id: id }) - .await; - // Réconcilie les lignes de live-state fantômes : un crash ne déclenche jamais - // `close_project`, donc l'ouverture est le seul filet fiable pour repasser en - // `idle` les agents `working`/`waiting`/`blocked` dont la session est morte. - // Acte système (appelle le port live-state directement, jamais via la surface - // MCP self-only). Best-effort : un échec ne bloque pas l'ouverture. Tourne - // AVANT toute relance d'agent (toute reprise réécrit ensuite en LWW). - let _ = state - .reconcile_live_state - .execute(ReconcileLiveStateInput { project_id: id }) - .await; - // Réconcilie les tâches de fond persistées au même point de boot que le - // live-state : les Running sans handle vivant deviennent terminales, les - // complétions WakeOwner non livrées repassent dans l'inbox unifiée. - let _ = state.reconcile_background_tasks.execute(id).await; - // (Re)start the orchestrator watcher for this project (idempotent, §14.3). - state.ensure_orchestrator_watch(&output.project); - state.reconcile_claude_run_dirs(&output.project).await; Ok(ProjectDto::from(output)) } @@ -633,10 +610,92 @@ pub async fn close_project(project_id: String, state: State<'_, AppState>) -> Re workspace: None, }) .await + .map_err(ErrorDto::from)?; + state + .record_project_closed_for_restore + .execute(id) + .await .map(|_| ()) .map_err(ErrorDto::from) } +/// Reads the ordered open-project restore state without opening projects. +#[tauri::command] +pub async fn read_open_project_restore_state( + state: State<'_, AppState>, +) -> Result { + state + .read_open_project_restore_state + .execute() + .await + .map(Into::into) + .map_err(ErrorDto::from) +} + +/// Restores all retained projects and applies normal-open runtime side effects. +#[tauri::command] +pub async fn restore_open_projects( + state: State<'_, AppState>, +) -> Result { + state + .restore_open_projects_and_activate() + .await + .map(Into::into) + .map_err(ErrorDto::from) +} + +/// Retries one retained entry, or all entries carrying a restore failure. +#[tauri::command] +pub async fn retry_open_project_restore( + request: RetryOpenProjectRestoreRequestDto, + state: State<'_, AppState>, +) -> Result { + let project_id = request + .project_id + .as_deref() + .map(parse_project_id) + .transpose()?; + state + .retry_open_project_restore_and_activate(project_id) + .await + .map(Into::into) + .map_err(ErrorDto::from) +} + +/// Removes one retained entry from future restoration. +#[tauri::command] +pub async fn remove_project_from_restore( + request: RemoveOpenProjectRestoreEntryRequestDto, + state: State<'_, AppState>, +) -> Result { + let project_id = parse_project_id(&request.project_id)?; + state + .remove_project_from_restore + .execute(project_id) + .await + .map(Into::into) + .map_err(ErrorDto::from) +} + +/// Persists a new visible order; omitted retained entries keep their prior order. +#[tauri::command] +pub async fn save_open_project_restore_order( + request: SaveOpenProjectRestoreOrderRequestDto, + state: State<'_, AppState>, +) -> Result { + let ordered_project_ids = request + .ordered_project_ids + .iter() + .map(|id| parse_project_id(id)) + .collect::, _>>()?; + state + .save_open_project_restore_order + .execute(ordered_project_ids) + .await + .map(Into::into) + .map_err(ErrorDto::from) +} + /// `list_projects` — list the projects known to the registry. /// /// # Errors diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index af6998b3..e05ff604 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -254,6 +254,11 @@ pub fn run() { commands::create_project, commands::open_project, commands::close_project, + commands::read_open_project_restore_state, + commands::restore_open_projects, + commands::retry_open_project_restore, + commands::remove_project_from_restore, + commands::save_open_project_restore_order, commands::list_projects, commands::read_project_context, commands::update_project_context, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index a0b8d457..acc4ebfb 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/src/project/mod.rs b/crates/application/src/project/mod.rs index 91c5d62f..6cf560db 100644 --- a/crates/application/src/project/mod.rs +++ b/crates/application/src/project/mod.rs @@ -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, +}; diff --git a/crates/application/src/project/restore.rs b/crates/application/src/project/restore.rs new file mode 100644 index 00000000..c6fa7820 --- /dev/null +++ b/crates/application/src/project/restore.rs @@ -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, + projects: Arc, + fs: Arc, + clock: Arc, + mutation: Mutex<()>, +} + +impl OpenProjectRestoreService { + /// Builds the shared service. + #[must_use] + pub fn new( + restore_store: Arc, + projects: Arc, + fs: Arc, + clock: Arc, + ) -> Self { + Self { + restore_store, + projects, + fs, + clock, + mutation: Mutex::new(()), + } + } + + async fn load(&self) -> Result { + self.restore_store + .load_open_project_restore_state() + .await + .map_err(AppError::from) + } + + async fn save( + &self, + mut state: OpenProjectRestoreState, + ) -> Result { + 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, + failures_only: bool, + ) -> Result { + 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 { + 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, + /// Failures, preserving their persisted positions. + pub failures: Vec, +} + +macro_rules! service_use_case { + ($name:ident) => { + /// Ordered open-project restore use case. + pub struct $name { + service: Arc, + } + + impl $name { + /// Builds the use case from the shared serialized service. + #[must_use] + pub fn new(service: Arc) -> 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 { + 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 { + 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 { + 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 { + 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, + ) -> Result { + 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::>(); + 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::>(); + 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 { + 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, + ) -> Result { + self.service.restore_matching(project_id, true).await + } +} diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 8b83c0b1..9b2f6ced 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -17,8 +17,8 @@ use application::{ CreateProjectInput, CreateProjectOutput, ExecuteSlashCommandOutput, GitGraphOutput, HealthInput, HealthReport, LayoutKind, ListProjectsOutput, ListSlashCommandsOutput, LiveRuntimeState, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, - SlashCommandEffect, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, - TurnSource, TurnView, + RestoreOpenProjectFailure, RestoreOpenProjectsOutput, SlashCommandEffect, StopLiveAgentOutput, + TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView, }; use domain::ports::{ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage}; use domain::{ @@ -1148,6 +1148,110 @@ impl From for ProjectDto { } } +/// One persisted entry in the ordered open-project restore state. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenProjectRestoreEntryDto { + pub project_id: String, + pub name: String, + pub root: String, + pub position: u32, + pub last_failure: Option, +} + +/// Machine-local ordered open-project restore state. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenProjectRestoreStateDto { + pub version: u32, + pub entries: Vec, + pub updated_at: i64, +} + +impl From for OpenProjectRestoreStateDto { + fn from(state: domain::OpenProjectRestoreState) -> Self { + Self { + version: state.version, + entries: state + .entries + .into_iter() + .map(|entry| OpenProjectRestoreEntryDto { + project_id: entry.project_id.to_string(), + name: entry.name, + root: entry.root, + position: entry.position, + last_failure: entry.last_failure, + }) + .collect(), + updated_at: state.updated_at, + } + } +} + +/// One non-blocking project restore failure. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RestoreOpenProjectFailureDto { + pub project_id: String, + pub name: String, + pub root: String, + pub position: u32, + pub code: domain::OpenProjectRestoreFailureCode, + pub message: String, +} + +impl From for RestoreOpenProjectFailureDto { + fn from(failure: RestoreOpenProjectFailure) -> Self { + Self { + project_id: failure.project_id.to_string(), + name: failure.name, + root: failure.root, + position: failure.position, + code: failure.code, + message: failure.message, + } + } +} + +/// Projects restored successfully plus retained per-entry failures. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RestoreOpenProjectsOutputDto { + pub opened: Vec, + pub failures: Vec, +} + +impl From for RestoreOpenProjectsOutputDto { + fn from(output: RestoreOpenProjectsOutput) -> Self { + Self { + opened: output.opened.into_iter().map(ProjectDto::from).collect(), + failures: output.failures.into_iter().map(Into::into).collect(), + } + } +} + +/// Optional targeted retry request. Omit `projectId` to retry every failure. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RetryOpenProjectRestoreRequestDto { + #[serde(default)] + pub project_id: Option, +} + +/// Removes one retained entry from future restoration. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveOpenProjectRestoreEntryRequestDto { + pub project_id: String, +} + +/// Persists the visible open-project order. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveOpenProjectRestoreOrderRequestDto { + pub ordered_project_ids: Vec, +} + /// Parses a project-id string (UUID) coming from the frontend. /// /// # Errors diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 915fa09f..0266cc05 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -44,14 +44,16 @@ use application::{ PluginToolchainDiagnostics, PluginWorkspaceAccess, ProjectPluginResolver, ProposeContext, PurgeManagedTemp, QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, - ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, - ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, + ReadMemory, ReadMemoryIndex, ReadOpenProjectRestoreState, ReadProjectContext, ReadSkill, + ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, + ReconcilePluginMcpServers, RecordProjectClosedForRestore, RecordProjectOpenedForRestore, RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, ReloadPluginInput, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions, - ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, - ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, - SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile, + ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenProjects, RestoreOpenProjectsOutput, + RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore, ReviewPluginPackage, + RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, SaveEmbedderProfile, + SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder, SaveProfile, SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled, SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, @@ -72,12 +74,13 @@ use domain::ports::{ BackgroundTaskStore, ChatAttachmentStore, ChatDraftStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, ManagedTempStore, - McpToolPermissionStore, MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore, - PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, - PluginStorageStore, ProcessSpawner, ProfileStore, ProjectPluginSettingsStore, ProjectStore, - PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, - SprintStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, - ToolInvoker, WakeError, WakeReason, WindowStateStore, + McpToolPermissionStore, MemoryRecall, MemoryStore, ModelArtifactDownloader, + OpenProjectRestoreStore, PermissionStore, PluginManifestValidator, PluginMcpSupervisor, + PluginPackageStore, PluginRegistryStore, PluginStorageStore, ProcessSpawner, ProfileStore, + ProjectPluginSettingsStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe, + ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore, + StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker, + WakeError, WakeReason, WindowStateStore, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -100,20 +103,21 @@ use infrastructure::{ FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsChatDraftStore, FsConversationLog, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore, - FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, - FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, - FsProfileStore, FsProjectPluginSettingsStore, FsProjectStore, FsProviderSessionStore, - FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, - FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, - HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore, - InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, - LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, - ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, - PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, - StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider, - TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, - ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, - ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore, + FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, + FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectStore, + FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore, + FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, + HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, + IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, + LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, + LocalProcessSpawner, ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall, + OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader, + ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock, + SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer, + TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, + VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, + VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; pub mod dto; @@ -1789,6 +1793,20 @@ pub struct BackendCore { pub open_project: Arc, /// Close a project (persist state). pub close_project: Arc, + /// Read the ordered open-project restore intent. + pub read_open_project_restore_state: Arc, + /// Record a visibly opened project. + pub record_project_opened_for_restore: Arc, + /// Record an explicit project close. + pub record_project_closed_for_restore: Arc, + /// Persist a new visible project order. + pub save_open_project_restore_order: Arc, + /// Restore every retained project. + pub restore_open_projects: Arc, + /// Retry one or all retained restore failures. + pub retry_open_project_restore: Arc, + /// Remove one retained restore entry. + pub remove_project_from_restore: Arc, /// Close a tab. pub close_tab: Arc, /// List known projects. @@ -2342,6 +2360,10 @@ impl BackendCore { Arc::clone(&fs) as Arc, app_data_dir.to_string_lossy().into_owned(), )); + let open_project_restore_store = Arc::new(FsOpenProjectRestoreStore::new( + Arc::clone(&fs) as Arc, + app_data_dir.to_string_lossy().into_owned(), + )); let window_state_store = Arc::new(FsWindowStateStore::new( Arc::clone(&fs) as Arc, app_data_dir.to_string_lossy().into_owned(), @@ -2357,6 +2379,8 @@ impl BackendCore { // Port-typed handles for injection. let fs_port = Arc::clone(&fs) as Arc; let store_port = Arc::clone(&store) as Arc; + let open_project_restore_store_port = + Arc::clone(&open_project_restore_store) as Arc; let window_state_port = Arc::clone(&window_state_store) as Arc; let device_session_port = Arc::clone(&device_session_store) as Arc; let pair_attempt_limiter_port = @@ -2429,6 +2453,33 @@ impl BackendCore { Arc::clone(&fs_port), )); let close_project = Arc::new(CloseProject::new(Arc::clone(&store_port))); + let open_project_restore_service = Arc::new(application::OpenProjectRestoreService::new( + Arc::clone(&open_project_restore_store_port), + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&clock) as Arc, + )); + let read_open_project_restore_state = Arc::new(ReadOpenProjectRestoreState::new( + Arc::clone(&open_project_restore_service), + )); + let record_project_opened_for_restore = Arc::new(RecordProjectOpenedForRestore::new( + Arc::clone(&open_project_restore_service), + )); + let record_project_closed_for_restore = Arc::new(RecordProjectClosedForRestore::new( + Arc::clone(&open_project_restore_service), + )); + let save_open_project_restore_order = Arc::new(SaveOpenProjectRestoreOrder::new( + Arc::clone(&open_project_restore_service), + )); + let restore_open_projects = Arc::new(RestoreOpenProjects::new(Arc::clone( + &open_project_restore_service, + ))); + let retry_open_project_restore = Arc::new(RetryOpenProjectRestore::new(Arc::clone( + &open_project_restore_service, + ))); + let remove_project_from_restore = Arc::new(application::RemoveProjectFromRestore::new( + Arc::clone(&open_project_restore_service), + )); let close_tab = Arc::new(CloseTab::new(Arc::clone(&store_port))); let list_projects = Arc::new(ListProjects::new(Arc::clone(&store_port))); let list_project_files = Arc::new(ListProjectFiles::new(Arc::clone(&fs_port))); @@ -4017,6 +4068,13 @@ impl BackendCore { create_project, open_project, close_project, + read_open_project_restore_state, + record_project_opened_for_restore, + record_project_closed_for_restore, + save_open_project_restore_order, + restore_open_projects, + retry_open_project_restore, + remove_project_from_restore, close_tab, list_projects, list_project_files, @@ -4216,6 +4274,63 @@ impl BackendCore { } } + /// Opens one project, persists its visible-open intent, then applies the + /// complete activation side-effect sequence shared by every driving adapter. + pub async fn open_project_and_activate( + &self, + project_id: ProjectId, + ) -> Result { + let output = self + .open_project + .execute(application::OpenProjectInput { project_id }) + .await?; + self.record_project_opened_for_restore + .execute(output.project.clone()) + .await?; + self.activate_project(&output.project).await; + Ok(output) + } + + /// Applies the same best-effort runtime activation to a normally opened, + /// newly created, restored, or retried project. + pub async fn activate_project(&self, project: &Project) { + let project_id = project.id; + let _ = self + .reconcile_layouts + .execute(application::ReconcileLayoutsInput { project_id }) + .await; + let _ = self + .reconcile_live_state + .execute(application::ReconcileLiveStateInput { project_id }) + .await; + let _ = self.reconcile_background_tasks.execute(project_id).await; + self.ensure_orchestrator_watch(project); + self.reconcile_claude_run_dirs(project).await; + } + + /// Restores every retained project and activates each successful result. + pub async fn restore_open_projects_and_activate( + &self, + ) -> Result { + let output = self.restore_open_projects.execute().await?; + for project in &output.opened { + self.activate_project(project).await; + } + Ok(output) + } + + /// Retries retained failures (or one requested entry) and activates successes. + pub async fn retry_open_project_restore_and_activate( + &self, + project_id: Option, + ) -> Result { + let output = self.retry_open_project_restore.execute(project_id).await?; + for project in &output.opened { + self.activate_project(project).await; + } + Ok(output) + } + /// Starts a [`FsOrchestratorWatcher`] for `project` if one is not already /// running for it (idempotent). The watcher tails the project's /// `.ideai/requests/` tree and dispatches each request through the shared @@ -8168,3 +8283,166 @@ mod bind_endpoint_d1_tests { ); } } + +#[cfg(test)] +mod open_project_restore_tests { + use super::BackendCore; + use application::CreateProjectInput; + use domain::{OpenProjectRestoreFailureCode, Project, ProjectId, ProjectPath, RemoteRef}; + use std::path::{Path, PathBuf}; + use uuid::Uuid; + + fn temp_path(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!("idea-restore-{tag}-{}", Uuid::new_v4())) + } + + async fn create_project(core: &BackendCore, root: &Path, name: &str) -> Project { + core.create_project + .execute(CreateProjectInput { + name: name.to_owned(), + root: root.to_string_lossy().into_owned(), + remote: None, + default_profile_id: None, + }) + .await + .expect("project creation succeeds") + .project + } + + #[tokio::test] + async fn restore_state_preserves_order_failures_retry_close_and_remove() { + let app_data = temp_path("appdata"); + let roots = temp_path("roots"); + let root_a = roots.join("a"); + let root_b = roots.join("b"); + let core = BackendCore::build(app_data.clone()); + + let empty = core + .restore_open_projects_and_activate() + .await + .expect("missing restore file is an empty successful restore"); + assert!(empty.opened.is_empty()); + assert!(empty.failures.is_empty()); + assert!(!app_data.join("open-projects.json").exists()); + + let project_a = create_project(&core, &root_a, "A").await; + let project_b = create_project(&core, &root_b, "B").await; + core.record_project_opened_for_restore + .execute(project_a.clone()) + .await + .expect("A is recorded"); + core.record_project_opened_for_restore + .execute(project_b.clone()) + .await + .expect("B is recorded"); + + let reordered = core + .save_open_project_restore_order + .execute(vec![project_b.id, project_a.id]) + .await + .expect("order is saved"); + assert_eq!( + reordered + .entries + .iter() + .map(|entry| entry.project_id) + .collect::>(), + vec![project_b.id, project_a.id] + ); + + core.record_project_closed_for_restore + .execute(project_b.id) + .await + .expect("explicit close removes B"); + let after_close = core + .read_open_project_restore_state + .execute() + .await + .expect("state is readable"); + assert_eq!(after_close.entries.len(), 1); + assert_eq!(after_close.entries[0].project_id, project_a.id); + + core.record_project_opened_for_restore + .execute(project_b.clone()) + .await + .expect("reopening B appends it"); + std::fs::remove_dir_all(&root_b).expect("temporary B root is removed"); + + let restored = core + .restore_open_projects_and_activate() + .await + .expect("one inaccessible project does not abort restore"); + assert_eq!( + restored + .opened + .iter() + .map(|project| project.id) + .collect::>(), + vec![project_a.id] + ); + assert_eq!(restored.failures.len(), 1); + assert_eq!(restored.failures[0].project_id, project_b.id); + assert_eq!( + restored.failures[0].code, + OpenProjectRestoreFailureCode::Inaccessible + ); + assert!(core.open_project_ids().contains(&project_a.id)); + assert!(core + .mcp_servers + .lock() + .expect("mcp server registry") + .contains_key(&project_a.id)); + + std::fs::create_dir_all(&root_b).expect("temporary B root is restored"); + let retried = core + .retry_open_project_restore_and_activate(Some(project_b.id)) + .await + .expect("targeted retry succeeds"); + assert_eq!(retried.opened.len(), 1); + assert_eq!(retried.opened[0].id, project_b.id); + assert!(core + .mcp_servers + .lock() + .expect("mcp server registry") + .contains_key(&project_b.id)); + + let unknown_id = ProjectId::from_uuid(Uuid::new_v4()); + let unknown = Project::new( + unknown_id, + "Missing", + ProjectPath::new(roots.join("missing").to_string_lossy().into_owned()) + .expect("absolute test path"), + RemoteRef::local(), + 0, + ) + .expect("valid retained project snapshot"); + core.record_project_opened_for_restore + .execute(unknown) + .await + .expect("unknown snapshot can be retained"); + let with_missing = core + .restore_open_projects + .execute() + .await + .expect("missing registry entry does not abort restore"); + assert_eq!( + with_missing.failures.last().map(|failure| failure.code), + Some(OpenProjectRestoreFailureCode::NotFound) + ); + + let removed = core + .remove_project_from_restore + .execute(unknown_id) + .await + .expect("retained failure can be removed"); + assert!(removed + .entries + .iter() + .all(|entry| entry.project_id != unknown_id)); + + core.stop_orchestrator_watch(&project_a.id); + core.stop_orchestrator_watch(&project_b.id); + let _ = std::fs::remove_dir_all(&app_data); + let _ = std::fs::remove_dir_all(&roots); + } +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index e93881df..767fbe09 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -62,6 +62,7 @@ pub mod plugin; pub mod ports; pub mod profile; pub mod project; +pub mod project_restore; pub mod readiness; pub mod remote; pub mod sandbox; @@ -88,6 +89,10 @@ pub use ids::{ }; pub use project::{Project, ProjectPath}; +pub use project_restore::{ + OpenProjectRestoreEntry, OpenProjectRestoreFailure, OpenProjectRestoreFailureCode, + OpenProjectRestoreState, OPEN_PROJECT_RESTORE_VERSION, +}; pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 4ae0246c..dd726789 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -2307,6 +2307,21 @@ pub trait ProjectStore: Send + Sync { async fn load_workspace(&self) -> Result; } +/// Machine-local persistence of the ordered open-project restore intent. +#[async_trait] +pub trait OpenProjectRestoreStore: Send + Sync { + /// Loads the document, returning an empty v1 state when it does not exist. + async fn load_open_project_restore_state( + &self, + ) -> Result; + + /// Persists the complete document. + async fn save_open_project_restore_state( + &self, + state: &crate::OpenProjectRestoreState, + ) -> Result<(), StoreError>; +} + /// Machine-local persistence of open OS window state. #[async_trait] pub trait WindowStateStore: Send + Sync { diff --git a/crates/domain/src/project_restore.rs b/crates/domain/src/project_restore.rs new file mode 100644 index 00000000..d33bb808 --- /dev/null +++ b/crates/domain/src/project_restore.rs @@ -0,0 +1,81 @@ +//! Machine-local intent for restoring projects that were visibly open. + +use serde::{Deserialize, Serialize}; + +use crate::ProjectId; + +/// Current on-disk schema version for `open-projects.json`. +pub const OPEN_PROJECT_RESTORE_VERSION: u32 = 1; + +/// Stable failure category retained with an unavailable restore entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum OpenProjectRestoreFailureCode { + /// Project no longer exists in the known-project registry. + NotFound, + /// Project root cannot currently be accessed. + Inaccessible, + /// A backing store failed. + Store, + /// Persisted data or project metadata is invalid. + Invalid, +} + +/// Last observed failure for one retained restore entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenProjectRestoreFailure { + /// Stable category. + pub code: OpenProjectRestoreFailureCode, + /// Human-readable diagnostic. + pub message: String, +} + +/// One project and its stable position in the visible open-project order. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenProjectRestoreEntry { + /// Project registry id. + pub project_id: ProjectId, + /// Last known display name, retained even if the registry entry disappears. + pub name: String, + /// Last known root, retained for failure details. + pub root: String, + /// Zero-based position in the restore order. + pub position: u32, + /// Last restore failure, cleared after a successful open. + #[serde(default)] + pub last_failure: Option, +} + +/// Complete machine-local restore document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenProjectRestoreState { + /// Schema version. + pub version: u32, + /// Entries in visible order. + pub entries: Vec, + /// Last mutation timestamp in epoch milliseconds. + pub updated_at: i64, +} + +impl Default for OpenProjectRestoreState { + fn default() -> Self { + Self { + version: OPEN_PROJECT_RESTORE_VERSION, + entries: Vec::new(), + updated_at: 0, + } + } +} + +impl OpenProjectRestoreState { + /// Sorts by persisted position and rewrites positions to a contiguous sequence. + pub fn normalize_positions(&mut self) { + self.entries.sort_by_key(|entry| entry.position); + for (position, entry) in self.entries.iter_mut().enumerate() { + entry.position = position as u32; + } + } +} diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 33e32e43..8b0ffa88 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -119,9 +119,9 @@ pub use store::{ embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector, AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, - FsMcpToolPermissionStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, - FsSecretStore, FsSkillStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, - HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, - VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, - VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + FsMcpToolPermissionStore, FsMemoryStore, FsOpenProjectRestoreStore, FsPermissionStore, + FsProfileStore, FsProjectStore, FsSecretStore, FsSkillStore, FsSystemPermissionStore, + FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, + OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, + RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index 17b7e665..9ff80969 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -11,6 +11,7 @@ mod embedder; mod live_state; mod mcp_tool_permission; mod memory; +mod open_project_restore; mod permission; mod profile; mod project; @@ -37,6 +38,7 @@ pub use embedder::{ pub use live_state::FsLiveStateStore; pub use mcp_tool_permission::FsMcpToolPermissionStore; pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall}; +pub use open_project_restore::FsOpenProjectRestoreStore; pub use permission::FsPermissionStore; pub use profile::{FsEmbedderProfileStore, FsProfileStore}; pub use project::FsProjectStore; diff --git a/crates/infrastructure/src/store/open_project_restore.rs b/crates/infrastructure/src/store/open_project_restore.rs new file mode 100644 index 00000000..f3fbec60 --- /dev/null +++ b/crates/infrastructure/src/store/open_project_restore.rs @@ -0,0 +1,76 @@ +//! JSON persistence for the ordered open-project restore intent. + +use std::sync::Arc; + +use async_trait::async_trait; +use domain::ports::{FileSystem, OpenProjectRestoreStore, RemotePath, StoreError}; +use domain::{OpenProjectRestoreState, OPEN_PROJECT_RESTORE_VERSION}; + +const FILE_NAME: &str = "open-projects.json"; + +/// Filesystem-backed restore store under the machine-local app-data directory. +#[derive(Clone)] +pub struct FsOpenProjectRestoreStore { + fs: Arc, + app_data_dir: String, +} + +impl FsOpenProjectRestoreStore { + /// Builds the store. + #[must_use] + pub fn new(fs: Arc, app_data_dir: impl Into) -> Self { + Self { + fs, + app_data_dir: app_data_dir.into(), + } + } + + fn path(&self) -> RemotePath { + RemotePath::new(format!( + "{}/{}", + self.app_data_dir.trim_end_matches(['/', '\\']), + FILE_NAME + )) + } +} + +#[async_trait] +impl OpenProjectRestoreStore for FsOpenProjectRestoreStore { + async fn load_open_project_restore_state(&self) -> Result { + match self.fs.read(&self.path()).await { + Ok(bytes) => { + let mut state: OpenProjectRestoreState = serde_json::from_slice(&bytes) + .map_err(|error| StoreError::Serialization(error.to_string()))?; + if state.version != OPEN_PROJECT_RESTORE_VERSION { + return Err(StoreError::Invalid(format!( + "unsupported open-project restore version {}", + state.version + ))); + } + state.normalize_positions(); + Ok(state) + } + Err(domain::ports::FsError::NotFound(_)) => Ok(OpenProjectRestoreState::default()), + Err(error) => Err(StoreError::Io(error.to_string())), + } + } + + async fn save_open_project_restore_state( + &self, + state: &OpenProjectRestoreState, + ) -> Result<(), StoreError> { + self.fs + .create_dir_all(&RemotePath::new( + self.app_data_dir.trim_end_matches(['/', '\\']).to_owned(), + )) + .await + .map_err(|error| StoreError::Io(error.to_string()))?; + let mut bytes = serde_json::to_vec_pretty(state) + .map_err(|error| StoreError::Serialization(error.to_string()))?; + bytes.push(b'\n'); + self.fs + .write(&self.path(), &bytes) + .await + .map_err(|error| StoreError::Io(error.to_string())) + } +} diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 16d0bf68..0177e2b7 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -53,17 +53,16 @@ use application::{ PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput, ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, - ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, - RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput, - ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, - RotateConversationLogInput, SetActiveLayoutInput, SetAgentMcpToolAllowedInput, - StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput, - UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContactableInput, - UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, - UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, - UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, - UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, - WriteToTerminalInput, + RenameDeviceInput, RenameLayoutInput, RenameSprintInput, ReorderSprintsInput, + ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, + ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput, + SetAgentMcpToolAllowedInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, + UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, + UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput, + UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, + UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput, + UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, + UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput, }; use domain::ports::{ PtyHandle, ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, @@ -92,19 +91,21 @@ use backend::dto::{ GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, - OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, - ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, - ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, - ReadConversationPageRequestDto, RecallMemoryRequestDto, RenameLayoutRequestDto, + OpenProjectRestoreStateDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, + ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, + ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, + ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto, + RemoveOpenProjectRestoreEntryRequestDto, RenameLayoutRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, - ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto, - SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, - SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, - SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto, - SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, - SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, - TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto, + RestoreOpenProjectsOutputDto, ResumableAgentListDto, RetryOpenProjectRestoreRequestDto, + SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto, + SaveOpenProjectRestoreOrderRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, + SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto, + SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto, + SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto, + StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, + TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto, TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto, TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput, @@ -3346,6 +3347,19 @@ async fn invoke( "health" => invoke_health(&request.args, &state.app), "list_projects" => invoke_list_projects(&state.app).await, "open_project" => invoke_open_project(&request.args, &state.app).await, + "read_open_project_restore_state" => { + invoke_read_open_project_restore_state(&state.app).await + } + "restore_open_projects" => invoke_restore_open_projects(&state.app).await, + "retry_open_project_restore" => { + invoke_retry_open_project_restore(&request.args, &state.app).await + } + "remove_project_from_restore" => { + invoke_remove_project_from_restore(&request.args, &state.app).await + } + "save_open_project_restore_order" => { + invoke_save_open_project_restore_order(&request.args, &state.app).await + } "load_layout" => invoke_load_layout(&request.args, &state.app).await, "mutate_layout" => invoke_mutate_layout(&request.args, &state.app).await, "list_layouts" => invoke_list_layouts(&request.args, &state.app).await, @@ -3544,24 +3558,89 @@ async fn open_project_for_web( ) -> Result { let id = parse_project_id(project_id)?; let output = state - .open_project - .execute(OpenProjectInput { project_id: id }) + .open_project_and_activate(id) .await .map_err(ErrorDto::from)?; - let _ = state - .reconcile_layouts - .execute(ReconcileLayoutsInput { project_id: id }) - .await; - let _ = state - .reconcile_live_state - .execute(ReconcileLiveStateInput { project_id: id }) - .await; - let _ = state.reconcile_background_tasks.execute(id).await; - state.ensure_orchestrator_watch(&output.project); - state.reconcile_claude_run_dirs(&output.project).await; Ok(ProjectDto::from(output)) } +async fn invoke_read_open_project_restore_state(state: &BackendCore) -> Result { + let output = state + .read_open_project_restore_state + .execute() + .await + .map(OpenProjectRestoreStateDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_restore_open_projects(state: &BackendCore) -> Result { + let output = state + .restore_open_projects_and_activate() + .await + .map(RestoreOpenProjectsOutputDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_retry_open_project_restore( + args: &Value, + state: &BackendCore, +) -> Result { + let request = optional_request::(args)?.unwrap_or_default(); + let project_id = request + .project_id + .as_deref() + .map(parse_project_id) + .transpose()?; + let output = state + .retry_open_project_restore_and_activate(project_id) + .await + .map(RestoreOpenProjectsOutputDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_remove_project_from_restore( + args: &Value, + state: &BackendCore, +) -> Result { + let request = required_request::( + "remove_project_from_restore", + args, + )?; + let project_id = parse_project_id(&request.project_id)?; + let output = state + .remove_project_from_restore + .execute(project_id) + .await + .map(OpenProjectRestoreStateDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_save_open_project_restore_order( + args: &Value, + state: &BackendCore, +) -> Result { + let request = required_request::( + "save_open_project_restore_order", + args, + )?; + let ordered_project_ids = request + .ordered_project_ids + .iter() + .map(|id| parse_project_id(id)) + .collect::, _>>()?; + let output = state + .save_open_project_restore_order + .execute(ordered_project_ids) + .await + .map(OpenProjectRestoreStateDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + async fn invoke_load_layout(args: &Value, state: &BackendCore) -> Result { let request = required_request::("load_layout", args)?; let project_id = parse_project_id(&request.project_id)?; @@ -9538,6 +9617,51 @@ mod tests { .is_some_and(|root| root.contains("idea-server-project-"))); } + #[tokio::test] + async fn authorized_open_project_restore_commands_share_the_tauri_contract() { + let state = state(); + let project_id = create_project_for_test(&state, "Restore Web").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let opened = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ "command": "open_project", "args": { "projectId": project_id } }), + &[("cookie", &cookie)], + ) + .await; + assert_eq!(response_json(opened).await.0, StatusCode::OK); + + let restored = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ "command": "restore_open_projects", "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(restored).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["opened"][0]["id"], project_id); + assert_eq!(body["failures"], json!([])); + + let removed = request( + state, + Method::POST, + "/api/invoke", + json!({ + "command": "remove_project_from_restore", + "args": { "request": { "projectId": project_id } } + }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(removed).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["entries"], json!([])); + } + #[tokio::test] async fn authorized_open_project_returns_readonly_project_dto() { let state = state(); diff --git a/frontend/src/adapters/http/httpInvoker.test.ts b/frontend/src/adapters/http/httpInvoker.test.ts index d6c8d197..10d49203 100644 --- a/frontend/src/adapters/http/httpInvoker.test.ts +++ b/frontend/src/adapters/http/httpInvoker.test.ts @@ -240,6 +240,30 @@ describe("request/response gateways preserve the Tauri command contract", () => }); }); + it("HttpProjectGateway maps the open-project restore command envelopes", async () => { + const http = new HttpInvoker({ baseUrl: "https://h" }); + const spy = vi.spyOn(http, "invoke").mockResolvedValue({} as never); + const gw = new HttpProjectGateway(http); + + await gw.restoreOpenProjects(); + await gw.retryOpenProjectRestore("project-1"); + await gw.removeProjectFromRestore("project-1"); + await gw.saveOpenProjectRestoreOrder(["project-2", "project-1"]); + await gw.readOpenProjectRestoreState(); + + expect(spy).toHaveBeenNthCalledWith(1, "restore_open_projects"); + expect(spy).toHaveBeenNthCalledWith(2, "retry_open_project_restore", { + request: { projectId: "project-1" }, + }); + expect(spy).toHaveBeenNthCalledWith(3, "remove_project_from_restore", { + request: { projectId: "project-1" }, + }); + expect(spy).toHaveBeenNthCalledWith(4, "save_open_project_restore_order", { + request: { orderedProjectIds: ["project-2", "project-1"] }, + }); + expect(spy).toHaveBeenNthCalledWith(5, "read_open_project_restore_state"); + }); + it("HttpProjectGateway project files use the dedicated REST endpoints", async () => { const http = new HttpInvoker({ baseUrl: "https://h" }); const request = vi.spyOn(http, "request").mockResolvedValue({ diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 4c2dcf78..a525d85d 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -38,6 +38,8 @@ import type { PermissionSet, Project, ProjectFileListing, + OpenProjectRestoreState, + RestoreOpenProjectsOutput, ProjectMcpToolPermissions, ProjectPermissions, ProjectWorkState, @@ -97,6 +99,29 @@ export class HttpProjectGateway implements ProjectGateway { async closeProject(projectId: string): Promise { await this.http.invoke("close_project", { projectId }); } + restoreOpenProjects(): Promise { + return this.http.invoke("restore_open_projects"); + } + retryOpenProjectRestore(projectId?: string): Promise { + return this.http.invoke("retry_open_project_restore", { + request: projectId === undefined ? {} : { projectId }, + }); + } + removeProjectFromRestore(projectId: string): Promise { + return this.http.invoke("remove_project_from_restore", { + request: { projectId }, + }); + } + saveOpenProjectRestoreOrder( + orderedProjectIds: string[], + ): Promise { + return this.http.invoke("save_open_project_restore_order", { + request: { orderedProjectIds }, + }); + } + readOpenProjectRestoreState(): Promise { + return this.http.invoke("read_open_project_restore_state"); + } readProjectContext(projectId: string): Promise { return this.http.invoke("read_project_context", { projectId }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 4df0da70..089e1a97 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -70,6 +70,9 @@ import type { PluginWorkspaceTextFile, Project, ProjectFileListing, + OpenProjectRestoreFailure, + OpenProjectRestoreState, + RestoreOpenProjectsOutput, JsonValue, ProjectMcpToolPermissions, ProjectPermissions, @@ -1247,6 +1250,29 @@ export class MockTerminalGateway implements TerminalGateway { export class MockProjectGateway implements ProjectGateway { private projects: Project[] = []; private contexts = new Map(); + private restoreProjectIds: string[] = []; + private restoreFailures = new Map(); + + /** Test/demo helper: defines the ordered projects retained for next launch. */ + setOpenProjectRestoreOrder(projectIds: string[]): void { + this.restoreProjectIds = [...projectIds]; + } + + /** Test/demo helper: makes one retained project fail until cleared. */ + setOpenProjectRestoreFailure( + projectId: string, + failure: Omit | null, + ): void { + if (failure === null) { + this.restoreFailures.delete(projectId); + return; + } + this.restoreFailures.set(projectId, { + projectId, + position: Math.max(0, this.restoreProjectIds.indexOf(projectId)), + ...failure, + }); + } async listProjects(): Promise { return [...this.projects]; @@ -1283,7 +1309,77 @@ export class MockProjectGateway implements ProjectGateway { return project; } - async closeProject(): Promise {} + async closeProject(projectId: string): Promise { + this.restoreProjectIds = this.restoreProjectIds.filter((id) => id !== projectId); + this.restoreFailures.delete(projectId); + } + + async restoreOpenProjects(): Promise { + const failures: OpenProjectRestoreFailure[] = []; + const opened: Project[] = []; + this.restoreProjectIds.forEach((projectId, position) => { + const configured = this.restoreFailures.get(projectId); + const project = this.projects.find((candidate) => candidate.id === projectId); + if (configured) failures.push({ ...configured, position }); + else if (project) opened.push(project); + else { + failures.push({ + projectId, + name: projectId, + root: "", + position, + code: "notFound", + message: "project is absent from the registry", + }); + } + }); + return { opened, failures }; + } + + async retryOpenProjectRestore(projectId?: string): Promise { + const output = await this.restoreOpenProjects(); + const selected = (id: string) => projectId === undefined || id === projectId; + return { + opened: output.opened.filter((project) => selected(project.id)), + failures: output.failures.filter((failure) => selected(failure.projectId)), + }; + } + + async removeProjectFromRestore(projectId: string): Promise { + this.restoreProjectIds = this.restoreProjectIds.filter((id) => id !== projectId); + this.restoreFailures.delete(projectId); + return this.readOpenProjectRestoreState(); + } + + async saveOpenProjectRestoreOrder( + orderedProjectIds: string[], + ): Promise { + const retained = this.restoreProjectIds.filter( + (id) => !orderedProjectIds.includes(id), + ); + this.restoreProjectIds = [...orderedProjectIds, ...retained]; + return this.readOpenProjectRestoreState(); + } + + async readOpenProjectRestoreState(): Promise { + return { + version: 1, + updatedAt: Date.now(), + entries: this.restoreProjectIds.map((projectId, position) => { + const project = this.projects.find((candidate) => candidate.id === projectId); + const failure = this.restoreFailures.get(projectId); + return { + projectId, + name: project?.name ?? failure?.name ?? projectId, + root: project?.root ?? failure?.root ?? "", + position, + lastFailure: failure + ? { code: failure.code, message: failure.message } + : null, + }; + }), + }; + } async readProjectContext(projectId: string): Promise { if (!this.projects.some((p) => p.id === projectId)) { diff --git a/frontend/src/adapters/project.test.ts b/frontend/src/adapters/project.test.ts index 3850062a..7c77d098 100644 --- a/frontend/src/adapters/project.test.ts +++ b/frontend/src/adapters/project.test.ts @@ -24,4 +24,33 @@ describe("TauriProjectGateway invoke payloads", () => { request: { projectId: "proj-1", content: "# next" }, }); }); + + it("maps the open-project restore commands to their exact request envelopes", async () => { + const gateway = new TauriProjectGateway(); + + await gateway.restoreOpenProjects(); + expect(invoke).toHaveBeenCalledWith("restore_open_projects"); + + await gateway.retryOpenProjectRestore(); + expect(invoke).toHaveBeenCalledWith("retry_open_project_restore", { + request: {}, + }); + await gateway.retryOpenProjectRestore("proj-1"); + expect(invoke).toHaveBeenCalledWith("retry_open_project_restore", { + request: { projectId: "proj-1" }, + }); + + await gateway.removeProjectFromRestore("proj-1"); + expect(invoke).toHaveBeenCalledWith("remove_project_from_restore", { + request: { projectId: "proj-1" }, + }); + + await gateway.saveOpenProjectRestoreOrder(["proj-2", "proj-1"]); + expect(invoke).toHaveBeenCalledWith("save_open_project_restore_order", { + request: { orderedProjectIds: ["proj-2", "proj-1"] }, + }); + + await gateway.readOpenProjectRestoreState(); + expect(invoke).toHaveBeenCalledWith("read_open_project_restore_state"); + }); }); diff --git a/frontend/src/adapters/project.ts b/frontend/src/adapters/project.ts index cd010566..53af5fc6 100644 --- a/frontend/src/adapters/project.ts +++ b/frontend/src/adapters/project.ts @@ -8,7 +8,12 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectFileListing } from "@/domain"; +import type { + OpenProjectRestoreState, + Project, + ProjectFileListing, + RestoreOpenProjectsOutput, +} from "@/domain"; import type { ProjectGateway } from "@/ports"; export class TauriProjectGateway implements ProjectGateway { @@ -28,6 +33,34 @@ export class TauriProjectGateway implements ProjectGateway { await invoke("close_project", { projectId }); } + restoreOpenProjects(): Promise { + return invoke("restore_open_projects"); + } + + retryOpenProjectRestore(projectId?: string): Promise { + return invoke("retry_open_project_restore", { + request: projectId === undefined ? {} : { projectId }, + }); + } + + removeProjectFromRestore(projectId: string): Promise { + return invoke("remove_project_from_restore", { + request: { projectId }, + }); + } + + saveOpenProjectRestoreOrder( + orderedProjectIds: string[], + ): Promise { + return invoke("save_open_project_restore_order", { + request: { orderedProjectIds }, + }); + } + + readOpenProjectRestoreState(): Promise { + return invoke("read_open_project_restore_state"); + } + readProjectContext(projectId: string): Promise { return invoke("read_project_context", { projectId }); } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 85a08dda..0e9ab7e5 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -606,6 +606,43 @@ export interface Project { createdAt: number; } +export type OpenProjectRestoreFailureCode = + | "notFound" + | "inaccessible" + | "store" + | "invalid"; + +export interface OpenProjectRestoreFailure { + projectId: string; + name: string; + root: string; + position: number; + code: OpenProjectRestoreFailureCode; + message: string; +} + +export interface OpenProjectRestoreEntry { + projectId: string; + name: string; + root: string; + position: number; + lastFailure: { + code: OpenProjectRestoreFailureCode; + message: string; + } | null; +} + +export interface OpenProjectRestoreState { + version: number; + entries: OpenProjectRestoreEntry[]; + updatedAt: number; +} + +export interface RestoreOpenProjectsOutput { + opened: Project[]; + failures: OpenProjectRestoreFailure[]; +} + /** Stable error shape mirrored from the backend `ErrorDto`. */ export interface GatewayError { code: string; diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 17e6308f..a61bb761 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -42,7 +42,12 @@ import { type ReactNode, } from "react"; -import type { Agent, DomainEvent, LayoutInfo } from "@/domain"; +import type { + Agent, + DomainEvent, + LayoutInfo, + OpenProjectRestoreFailure, +} from "@/domain"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; import { ConversationViewer } from "@/features/conversations"; import { @@ -207,6 +212,19 @@ function rendezvousToastStateLabel(state: string): string { } } +function restoreFailureCause(failure: OpenProjectRestoreFailure): string { + switch (failure.code) { + case "notFound": + return "Le projet n’est plus enregistré."; + case "inaccessible": + return "Le chemin est introuvable ou inaccessible."; + case "store": + return "L’état du projet n’a pas pu être lu."; + case "invalid": + return "Les informations mémorisées pour ce projet sont invalides."; + } +} + export function ProjectsView() { const vm = useProjects(); const projectActivity = useProjectActivity(vm.openTabs.map((tab) => tab.id)); @@ -247,10 +265,17 @@ export function ProjectsView() { useState(null); const [taskToasts, setTaskToasts] = useState([]); const [detachErrorPanel, setDetachErrorPanel] = useState(null); + const [restoreDetailsOpen, setRestoreDetailsOpen] = useState(false); const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null; const activeViewerConversation = viewerConversation?.projectId === active?.id ? viewerConversation : null; + const showRestoreNotification = + vm.restoreNotificationVisible && vm.restoreFailures.length > 0; + + useEffect(() => { + if (vm.restoreFailures.length === 0) setRestoreDetailsOpen(false); + }, [vm.restoreFailures.length]); const projectNameById = useMemo(() => { const names = new Map(); for (const project of vm.projects) names.set(project.id, project.name); @@ -1021,12 +1046,77 @@ export function ProjectsView() { ))} - {/* ── Background-task toasts (above floating windows) ── */} - {taskToasts.length > 0 && ( + 0} + title="Projets non rouverts" + size="md" + onClose={() => setRestoreDetailsOpen(false)} + > +
    + {vm.restoreFailures.map((failure) => ( +
  • +
    {failure.name}
    +
    {failure.root}
    +

    {restoreFailureCause(failure)}

    +
    + +
    +
  • + ))} +
+
+ + {/* ── Non-blocking notifications (above floating windows) ── */} + {(showRestoreNotification || taskToasts.length > 0) && (
+ {showRestoreNotification && ( +
+
+ + {vm.restoreFailures.length === 1 + ? "1 projet n’a pas pu être rouvert" + : `${vm.restoreFailures.length} projets n’ont pas pu être rouverts`} + + +
+
+ + +
+
+ )} {taskToasts.map((toast) => (