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:
@ -22,16 +22,15 @@ use application::{
|
|||||||
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
|
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
|
||||||
LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput,
|
LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput,
|
||||||
ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions,
|
ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions,
|
||||||
LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput,
|
LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput,
|
||||||
ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput,
|
ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput,
|
||||||
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput,
|
||||||
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
RenameLayoutInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput,
|
||||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SaveChatDraftInput,
|
||||||
RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput,
|
SetActiveLayoutInput, SetAgentMcpToolAllowedInput, SlashCommandEffect,
|
||||||
SetAgentMcpToolAllowedInput, SlashCommandEffect, SnapshotRunningAgentsInput,
|
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
||||||
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput,
|
||||||
UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||||
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
|
||||||
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
|
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
|
||||||
UpdateProjectMcpToolPermissionsInput, UpdateProjectOrchestratorInput,
|
UpdateProjectMcpToolPermissionsInput, UpdateProjectOrchestratorInput,
|
||||||
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||||
@ -65,20 +64,22 @@ use crate::dto::{
|
|||||||
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, ManagedTempGcResponseDto,
|
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, ManagedTempGcResponseDto,
|
||||||
ManagedTempHandleRequestDto, ManagedTempLeaseDto, ManagedTempLeaseResponseDto, MemoryDto,
|
ManagedTempHandleRequestDto, ManagedTempLeaseDto, ManagedTempLeaseResponseDto, MemoryDto,
|
||||||
MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto,
|
MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto,
|
||||||
OpenCodeProviderListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto,
|
OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto,
|
||||||
ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
|
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
|
||||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
||||||
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadChatDraftResponseDto,
|
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||||
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto,
|
||||||
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
RecallMemoryRequestDto, RemoveOpenProjectRestoreEntryRequestDto, RenameLayoutRequestDto,
|
||||||
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
|
||||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto,
|
||||||
ResumableAgentListDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto,
|
ResolvedAgentSystemPermissionsDto, RestoreOpenProjectsOutputDto, ResumableAgentListDto,
|
||||||
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto,
|
RetryOpenProjectRestoreRequestDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto,
|
||||||
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto,
|
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
||||||
SkillDto, SkillListDto, SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto,
|
SaveOpenProjectRestoreOrderRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
||||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto,
|
||||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
||||||
|
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||||
|
TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||||
UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
||||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||||
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||||
@ -438,9 +439,12 @@ pub async fn create_project(
|
|||||||
.execute(request.into())
|
.execute(request.into())
|
||||||
.await
|
.await
|
||||||
.map_err(ErrorDto::from)?;
|
.map_err(ErrorDto::from)?;
|
||||||
// Start tailing this project's `.ideai/requests/` tree so an orchestrator
|
state
|
||||||
// agent can delegate agent/skill creation to IdeA (§14.3).
|
.record_project_opened_for_restore
|
||||||
state.ensure_orchestrator_watch(&output.project);
|
.execute(output.project.clone())
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
state.activate_project(&output.project).await;
|
||||||
Ok(ProjectDto::from(output))
|
Ok(ProjectDto::from(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -467,36 +471,9 @@ pub(crate) async fn open_project_for_adapter(
|
|||||||
) -> Result<ProjectDto, ErrorDto> {
|
) -> Result<ProjectDto, ErrorDto> {
|
||||||
let id = parse_project_id(&project_id)?;
|
let id = parse_project_id(&project_id)?;
|
||||||
let output = state
|
let output = state
|
||||||
.open_project
|
.open_project_and_activate(id)
|
||||||
.execute(OpenProjectInput { project_id: id })
|
|
||||||
.await
|
.await
|
||||||
.map_err(ErrorDto::from)?;
|
.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))
|
Ok(ProjectDto::from(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -633,10 +610,92 @@ pub async fn close_project(project_id: String, state: State<'_, AppState>) -> Re
|
|||||||
workspace: None,
|
workspace: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
state
|
||||||
|
.record_project_closed_for_restore
|
||||||
|
.execute(id)
|
||||||
|
.await
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(ErrorDto::from)
|
.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<OpenProjectRestoreStateDto, ErrorDto> {
|
||||||
|
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<RestoreOpenProjectsOutputDto, ErrorDto> {
|
||||||
|
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<RestoreOpenProjectsOutputDto, ErrorDto> {
|
||||||
|
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<OpenProjectRestoreStateDto, ErrorDto> {
|
||||||
|
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<OpenProjectRestoreStateDto, ErrorDto> {
|
||||||
|
let ordered_project_ids = request
|
||||||
|
.ordered_project_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| parse_project_id(id))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
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.
|
/// `list_projects` — list the projects known to the registry.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
@ -254,6 +254,11 @@ pub fn run() {
|
|||||||
commands::create_project,
|
commands::create_project,
|
||||||
commands::open_project,
|
commands::open_project,
|
||||||
commands::close_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::list_projects,
|
||||||
commands::read_project_context,
|
commands::read_project_context,
|
||||||
commands::update_project_context,
|
commands::update_project_context,
|
||||||
|
|||||||
@ -204,10 +204,13 @@ pub use plugin::{
|
|||||||
pub use project::{
|
pub use project::{
|
||||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||||
CreateProjectInput, CreateProjectOutput, ListProjectFiles, ListProjectFilesInput, ListProjects,
|
CreateProjectInput, CreateProjectOutput, ListProjectFiles, ListProjectFilesInput, ListProjects,
|
||||||
ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput, ProjectFileEntry,
|
ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,
|
||||||
ProjectFileKind, ProjectFileListing, ProjectMeta, ReadProjectContext, ReadProjectContextInput,
|
OpenProjectRestoreService, ProjectFileEntry, ProjectFileKind, ProjectFileListing, ProjectMeta,
|
||||||
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
|
ReadOpenProjectRestoreState, ReadProjectContext, ReadProjectContextInput,
|
||||||
PROJECT_CONTEXT_FILE,
|
ReadProjectContextOutput, RecordProjectClosedForRestore, RecordProjectOpenedForRestore,
|
||||||
|
RemoveProjectFromRestore, RestoreOpenProjectFailure, RestoreOpenProjects,
|
||||||
|
RestoreOpenProjectsOutput, RetryOpenProjectRestore, SaveOpenProjectRestoreOrder,
|
||||||
|
UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
|
||||||
};
|
};
|
||||||
pub use project_plugin::{
|
pub use project_plugin::{
|
||||||
plugin_skill_ref, plugin_tool_ref, AssignmentMutation, ListProjectPluginSettings,
|
plugin_skill_ref, plugin_tool_ref, AssignmentMutation, ListProjectPluginSettings,
|
||||||
|
|||||||
@ -19,6 +19,7 @@ mod create;
|
|||||||
mod files;
|
mod files;
|
||||||
pub(crate) mod meta;
|
pub(crate) mod meta;
|
||||||
mod open;
|
mod open;
|
||||||
|
mod restore;
|
||||||
|
|
||||||
pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput};
|
pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput};
|
||||||
pub use context::{
|
pub use context::{
|
||||||
@ -34,3 +35,9 @@ pub use meta::ProjectMeta;
|
|||||||
pub use open::{
|
pub use open::{
|
||||||
ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,8 +17,8 @@ use application::{
|
|||||||
CreateProjectInput, CreateProjectOutput, ExecuteSlashCommandOutput, GitGraphOutput,
|
CreateProjectInput, CreateProjectOutput, ExecuteSlashCommandOutput, GitGraphOutput,
|
||||||
HealthInput, HealthReport, LayoutKind, ListProjectsOutput, ListSlashCommandsOutput,
|
HealthInput, HealthReport, LayoutKind, ListProjectsOutput, ListSlashCommandsOutput,
|
||||||
LiveRuntimeState, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState,
|
LiveRuntimeState, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState,
|
||||||
SlashCommandEffect, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage,
|
RestoreOpenProjectFailure, RestoreOpenProjectsOutput, SlashCommandEffect, StopLiveAgentOutput,
|
||||||
TurnSource, TurnView,
|
TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView,
|
||||||
};
|
};
|
||||||
use domain::ports::{ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage};
|
use domain::ports::{ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage};
|
||||||
use domain::{
|
use domain::{
|
||||||
@ -1148,6 +1148,110 @@ impl From<OpenProjectOutput> 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<domain::OpenProjectRestoreFailure>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Machine-local ordered open-project restore state.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OpenProjectRestoreStateDto {
|
||||||
|
pub version: u32,
|
||||||
|
pub entries: Vec<OpenProjectRestoreEntryDto>,
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::OpenProjectRestoreState> 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<RestoreOpenProjectFailure> 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<ProjectDto>,
|
||||||
|
pub failures: Vec<RestoreOpenProjectFailureDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RestoreOpenProjectsOutput> 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parses a project-id string (UUID) coming from the frontend.
|
/// Parses a project-id string (UUID) coming from the frontend.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
@ -44,14 +44,16 @@ use application::{
|
|||||||
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProjectPluginResolver, ProposeContext,
|
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProjectPluginResolver, ProposeContext,
|
||||||
PurgeManagedTemp, QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext,
|
PurgeManagedTemp, QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext,
|
||||||
ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions,
|
ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions,
|
||||||
ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory,
|
ReadMemory, ReadMemoryIndex, ReadOpenProjectRestoreState, ReadProjectContext, ReadSkill,
|
||||||
ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers,
|
ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput,
|
||||||
|
ReconcilePluginMcpServers, RecordProjectClosedForRestore, RecordProjectOpenedForRestore,
|
||||||
RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin,
|
RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin,
|
||||||
ReloadPluginInput, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
|
ReloadPluginInput, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
|
||||||
ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions,
|
ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions,
|
||||||
ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenProjects, RestoreOpenProjectsOutput,
|
||||||
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
|
RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore, ReviewPluginPackage,
|
||||||
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
|
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, SaveEmbedderProfile,
|
||||||
|
SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder, SaveProfile,
|
||||||
SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled,
|
SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled,
|
||||||
SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
|
SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
|
||||||
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
||||||
@ -72,12 +74,13 @@ use domain::ports::{
|
|||||||
BackgroundTaskStore, ChatAttachmentStore, ChatDraftStore, Clock, DeviceSessionStore, Embedder,
|
BackgroundTaskStore, ChatAttachmentStore, ChatDraftStore, Clock, DeviceSessionStore, Embedder,
|
||||||
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus,
|
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus,
|
||||||
FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, ManagedTempStore,
|
FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, ManagedTempStore,
|
||||||
McpToolPermissionStore, MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore,
|
McpToolPermissionStore, MemoryRecall, MemoryStore, ModelArtifactDownloader,
|
||||||
PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore,
|
OpenProjectRestoreStore, PermissionStore, PluginManifestValidator, PluginMcpSupervisor,
|
||||||
PluginStorageStore, ProcessSpawner, ProfileStore, ProjectPluginSettingsStore, ProjectStore,
|
PluginPackageStore, PluginRegistryStore, PluginStorageStore, ProcessSpawner, ProfileStore,
|
||||||
PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore,
|
ProjectPluginSettingsStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe,
|
||||||
SprintStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore,
|
ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
||||||
ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
|
||||||
|
WakeError, WakeReason, WindowStateStore,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
@ -100,20 +103,21 @@ use infrastructure::{
|
|||||||
FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsChatDraftStore,
|
FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsChatDraftStore,
|
||||||
FsConversationLog, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
FsConversationLog, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||||
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore,
|
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore,
|
||||||
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher,
|
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore,
|
||||||
FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
|
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
||||||
FsProfileStore, FsProjectPluginSettingsStore, FsProjectStore, FsProviderSessionStore,
|
FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectStore,
|
||||||
FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore,
|
FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore,
|
||||||
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer,
|
||||||
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
|
HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue,
|
||||||
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter,
|
||||||
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner,
|
LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess,
|
||||||
ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
|
LocalProcessSpawner, ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||||
PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard,
|
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
|
||||||
StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider,
|
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
|
||||||
TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||||
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
||||||
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||||
|
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod dto;
|
pub mod dto;
|
||||||
@ -1789,6 +1793,20 @@ pub struct BackendCore {
|
|||||||
pub open_project: Arc<OpenProject>,
|
pub open_project: Arc<OpenProject>,
|
||||||
/// Close a project (persist state).
|
/// Close a project (persist state).
|
||||||
pub close_project: Arc<CloseProject>,
|
pub close_project: Arc<CloseProject>,
|
||||||
|
/// Read the ordered open-project restore intent.
|
||||||
|
pub read_open_project_restore_state: Arc<ReadOpenProjectRestoreState>,
|
||||||
|
/// Record a visibly opened project.
|
||||||
|
pub record_project_opened_for_restore: Arc<RecordProjectOpenedForRestore>,
|
||||||
|
/// Record an explicit project close.
|
||||||
|
pub record_project_closed_for_restore: Arc<RecordProjectClosedForRestore>,
|
||||||
|
/// Persist a new visible project order.
|
||||||
|
pub save_open_project_restore_order: Arc<SaveOpenProjectRestoreOrder>,
|
||||||
|
/// Restore every retained project.
|
||||||
|
pub restore_open_projects: Arc<RestoreOpenProjects>,
|
||||||
|
/// Retry one or all retained restore failures.
|
||||||
|
pub retry_open_project_restore: Arc<RetryOpenProjectRestore>,
|
||||||
|
/// Remove one retained restore entry.
|
||||||
|
pub remove_project_from_restore: Arc<application::RemoveProjectFromRestore>,
|
||||||
/// Close a tab.
|
/// Close a tab.
|
||||||
pub close_tab: Arc<CloseTab>,
|
pub close_tab: Arc<CloseTab>,
|
||||||
/// List known projects.
|
/// List known projects.
|
||||||
@ -2342,6 +2360,10 @@ impl BackendCore {
|
|||||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||||
app_data_dir.to_string_lossy().into_owned(),
|
app_data_dir.to_string_lossy().into_owned(),
|
||||||
));
|
));
|
||||||
|
let open_project_restore_store = Arc::new(FsOpenProjectRestoreStore::new(
|
||||||
|
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||||
|
app_data_dir.to_string_lossy().into_owned(),
|
||||||
|
));
|
||||||
let window_state_store = Arc::new(FsWindowStateStore::new(
|
let window_state_store = Arc::new(FsWindowStateStore::new(
|
||||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||||
app_data_dir.to_string_lossy().into_owned(),
|
app_data_dir.to_string_lossy().into_owned(),
|
||||||
@ -2357,6 +2379,8 @@ impl BackendCore {
|
|||||||
// Port-typed handles for injection.
|
// Port-typed handles for injection.
|
||||||
let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
|
let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
|
||||||
let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>;
|
let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>;
|
||||||
|
let open_project_restore_store_port =
|
||||||
|
Arc::clone(&open_project_restore_store) as Arc<dyn OpenProjectRestoreStore>;
|
||||||
let window_state_port = Arc::clone(&window_state_store) as Arc<dyn WindowStateStore>;
|
let window_state_port = Arc::clone(&window_state_store) as Arc<dyn WindowStateStore>;
|
||||||
let device_session_port = Arc::clone(&device_session_store) as Arc<dyn DeviceSessionStore>;
|
let device_session_port = Arc::clone(&device_session_store) as Arc<dyn DeviceSessionStore>;
|
||||||
let pair_attempt_limiter_port =
|
let pair_attempt_limiter_port =
|
||||||
@ -2429,6 +2453,33 @@ impl BackendCore {
|
|||||||
Arc::clone(&fs_port),
|
Arc::clone(&fs_port),
|
||||||
));
|
));
|
||||||
let close_project = Arc::new(CloseProject::new(Arc::clone(&store_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<dyn Clock>,
|
||||||
|
));
|
||||||
|
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 close_tab = Arc::new(CloseTab::new(Arc::clone(&store_port)));
|
||||||
let list_projects = Arc::new(ListProjects::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)));
|
let list_project_files = Arc::new(ListProjectFiles::new(Arc::clone(&fs_port)));
|
||||||
@ -4017,6 +4068,13 @@ impl BackendCore {
|
|||||||
create_project,
|
create_project,
|
||||||
open_project,
|
open_project,
|
||||||
close_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,
|
close_tab,
|
||||||
list_projects,
|
list_projects,
|
||||||
list_project_files,
|
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<application::OpenProjectOutput, AppError> {
|
||||||
|
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<RestoreOpenProjectsOutput, AppError> {
|
||||||
|
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<ProjectId>,
|
||||||
|
) -> Result<RestoreOpenProjectsOutput, AppError> {
|
||||||
|
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
|
/// Starts a [`FsOrchestratorWatcher`] for `project` if one is not already
|
||||||
/// running for it (idempotent). The watcher tails the project's
|
/// running for it (idempotent). The watcher tails the project's
|
||||||
/// `.ideai/requests/` tree and dispatches each request through the shared
|
/// `.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<_>>(),
|
||||||
|
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<_>>(),
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -62,6 +62,7 @@ pub mod plugin;
|
|||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
|
pub mod project_restore;
|
||||||
pub mod readiness;
|
pub mod readiness;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
@ -88,6 +89,10 @@ pub use ids::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub use project::{Project, ProjectPath};
|
pub use project::{Project, ProjectPath};
|
||||||
|
pub use project_restore::{
|
||||||
|
OpenProjectRestoreEntry, OpenProjectRestoreFailure, OpenProjectRestoreFailureCode,
|
||||||
|
OpenProjectRestoreState, OPEN_PROJECT_RESTORE_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||||
|
|
||||||
|
|||||||
@ -2307,6 +2307,21 @@ pub trait ProjectStore: Send + Sync {
|
|||||||
async fn load_workspace(&self) -> Result<crate::layout::Workspace, StoreError>;
|
async fn load_workspace(&self) -> Result<crate::layout::Workspace, StoreError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<crate::OpenProjectRestoreState, StoreError>;
|
||||||
|
|
||||||
|
/// 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.
|
/// Machine-local persistence of open OS window state.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait WindowStateStore: Send + Sync {
|
pub trait WindowStateStore: Send + Sync {
|
||||||
|
|||||||
81
crates/domain/src/project_restore.rs
Normal file
81
crates/domain/src/project_restore.rs
Normal file
@ -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<OpenProjectRestoreFailure>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<OpenProjectRestoreEntry>,
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -119,9 +119,9 @@ pub use store::{
|
|||||||
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
||||||
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
||||||
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
|
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
|
||||||
FsMcpToolPermissionStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
|
FsMcpToolPermissionStore, FsMemoryStore, FsOpenProjectRestoreStore, FsPermissionStore,
|
||||||
FsSecretStore, FsSkillStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore,
|
FsProfileStore, FsProjectStore, FsSecretStore, FsSkillStore, FsSystemPermissionStore,
|
||||||
HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder,
|
FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall,
|
||||||
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||||
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,6 +11,7 @@ mod embedder;
|
|||||||
mod live_state;
|
mod live_state;
|
||||||
mod mcp_tool_permission;
|
mod mcp_tool_permission;
|
||||||
mod memory;
|
mod memory;
|
||||||
|
mod open_project_restore;
|
||||||
mod permission;
|
mod permission;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod project;
|
mod project;
|
||||||
@ -37,6 +38,7 @@ pub use embedder::{
|
|||||||
pub use live_state::FsLiveStateStore;
|
pub use live_state::FsLiveStateStore;
|
||||||
pub use mcp_tool_permission::FsMcpToolPermissionStore;
|
pub use mcp_tool_permission::FsMcpToolPermissionStore;
|
||||||
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
||||||
|
pub use open_project_restore::FsOpenProjectRestoreStore;
|
||||||
pub use permission::FsPermissionStore;
|
pub use permission::FsPermissionStore;
|
||||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||||
pub use project::FsProjectStore;
|
pub use project::FsProjectStore;
|
||||||
|
|||||||
76
crates/infrastructure/src/store/open_project_restore.rs
Normal file
76
crates/infrastructure/src/store/open_project_restore.rs
Normal file
@ -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<dyn FileSystem>,
|
||||||
|
app_data_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsOpenProjectRestoreStore {
|
||||||
|
/// Builds the store.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> 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<OpenProjectRestoreState, StoreError> {
|
||||||
|
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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -53,17 +53,16 @@ use application::{
|
|||||||
PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
|
PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
|
||||||
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
|
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
|
||||||
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
||||||
ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput,
|
RenameDeviceInput, RenameLayoutInput, RenameSprintInput, ReorderSprintsInput,
|
||||||
RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
|
ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput,
|
||||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput,
|
||||||
RotateConversationLogInput, SetActiveLayoutInput, SetAgentMcpToolAllowedInput,
|
SetAgentMcpToolAllowedInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
|
||||||
StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
|
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
|
||||||
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContactableInput,
|
UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
||||||
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||||
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
|
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
|
||||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
|
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||||
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
|
||||||
WriteToTerminalInput,
|
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
PtyHandle, ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource,
|
PtyHandle, ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource,
|
||||||
@ -92,19 +91,21 @@ use backend::dto::{
|
|||||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto,
|
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto,
|
||||||
OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
|
OpenProjectRestoreStateDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
|
||||||
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||||
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||||
ReadConversationPageRequestDto, RecallMemoryRequestDto, RenameLayoutRequestDto,
|
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
||||||
|
RemoveOpenProjectRestoreEntryRequestDto, RenameLayoutRequestDto,
|
||||||
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
RestoreOpenProjectsOutputDto, ResumableAgentListDto, RetryOpenProjectRestoreRequestDto,
|
||||||
SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto,
|
SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
||||||
SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto,
|
SaveOpenProjectRestoreOrderRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
||||||
SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto,
|
SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto,
|
||||||
SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto,
|
||||||
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
|
||||||
TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
|
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||||
|
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
|
||||||
TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto,
|
TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto,
|
||||||
TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto,
|
TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto,
|
||||||
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
|
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
|
||||||
@ -3346,6 +3347,19 @@ async fn invoke(
|
|||||||
"health" => invoke_health(&request.args, &state.app),
|
"health" => invoke_health(&request.args, &state.app),
|
||||||
"list_projects" => invoke_list_projects(&state.app).await,
|
"list_projects" => invoke_list_projects(&state.app).await,
|
||||||
"open_project" => invoke_open_project(&request.args, &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,
|
"load_layout" => invoke_load_layout(&request.args, &state.app).await,
|
||||||
"mutate_layout" => invoke_mutate_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,
|
"list_layouts" => invoke_list_layouts(&request.args, &state.app).await,
|
||||||
@ -3544,24 +3558,89 @@ async fn open_project_for_web(
|
|||||||
) -> Result<ProjectDto, ErrorDto> {
|
) -> Result<ProjectDto, ErrorDto> {
|
||||||
let id = parse_project_id(project_id)?;
|
let id = parse_project_id(project_id)?;
|
||||||
let output = state
|
let output = state
|
||||||
.open_project
|
.open_project_and_activate(id)
|
||||||
.execute(OpenProjectInput { project_id: id })
|
|
||||||
.await
|
.await
|
||||||
.map_err(ErrorDto::from)?;
|
.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))
|
Ok(ProjectDto::from(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn invoke_read_open_project_restore_state(state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
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<Value, ErrorDto> {
|
||||||
|
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<Value, ErrorDto> {
|
||||||
|
let request = optional_request::<RetryOpenProjectRestoreRequestDto>(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<Value, ErrorDto> {
|
||||||
|
let request = required_request::<RemoveOpenProjectRestoreEntryRequestDto>(
|
||||||
|
"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<Value, ErrorDto> {
|
||||||
|
let request = required_request::<SaveOpenProjectRestoreOrderRequestDto>(
|
||||||
|
"save_open_project_restore_order",
|
||||||
|
args,
|
||||||
|
)?;
|
||||||
|
let ordered_project_ids = request
|
||||||
|
.ordered_project_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| parse_project_id(id))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
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<Value, ErrorDto> {
|
async fn invoke_load_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
let request = required_request::<LoadLayoutInvokeArgs>("load_layout", args)?;
|
let request = required_request::<LoadLayoutInvokeArgs>("load_layout", args)?;
|
||||||
let project_id = parse_project_id(&request.project_id)?;
|
let project_id = parse_project_id(&request.project_id)?;
|
||||||
@ -9538,6 +9617,51 @@ mod tests {
|
|||||||
.is_some_and(|root| root.contains("idea-server-project-")));
|
.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]
|
#[tokio::test]
|
||||||
async fn authorized_open_project_returns_readonly_project_dto() {
|
async fn authorized_open_project_returns_readonly_project_dto() {
|
||||||
let state = state();
|
let state = state();
|
||||||
|
|||||||
@ -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 () => {
|
it("HttpProjectGateway project files use the dedicated REST endpoints", async () => {
|
||||||
const http = new HttpInvoker({ baseUrl: "https://h" });
|
const http = new HttpInvoker({ baseUrl: "https://h" });
|
||||||
const request = vi.spyOn(http, "request").mockResolvedValue({
|
const request = vi.spyOn(http, "request").mockResolvedValue({
|
||||||
|
|||||||
@ -38,6 +38,8 @@ import type {
|
|||||||
PermissionSet,
|
PermissionSet,
|
||||||
Project,
|
Project,
|
||||||
ProjectFileListing,
|
ProjectFileListing,
|
||||||
|
OpenProjectRestoreState,
|
||||||
|
RestoreOpenProjectsOutput,
|
||||||
ProjectMcpToolPermissions,
|
ProjectMcpToolPermissions,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
ProjectWorkState,
|
ProjectWorkState,
|
||||||
@ -97,6 +99,29 @@ export class HttpProjectGateway implements ProjectGateway {
|
|||||||
async closeProject(projectId: string): Promise<void> {
|
async closeProject(projectId: string): Promise<void> {
|
||||||
await this.http.invoke("close_project", { projectId });
|
await this.http.invoke("close_project", { projectId });
|
||||||
}
|
}
|
||||||
|
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput> {
|
||||||
|
return this.http.invoke<RestoreOpenProjectsOutput>("restore_open_projects");
|
||||||
|
}
|
||||||
|
retryOpenProjectRestore(projectId?: string): Promise<RestoreOpenProjectsOutput> {
|
||||||
|
return this.http.invoke<RestoreOpenProjectsOutput>("retry_open_project_restore", {
|
||||||
|
request: projectId === undefined ? {} : { projectId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
removeProjectFromRestore(projectId: string): Promise<OpenProjectRestoreState> {
|
||||||
|
return this.http.invoke<OpenProjectRestoreState>("remove_project_from_restore", {
|
||||||
|
request: { projectId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
saveOpenProjectRestoreOrder(
|
||||||
|
orderedProjectIds: string[],
|
||||||
|
): Promise<OpenProjectRestoreState> {
|
||||||
|
return this.http.invoke<OpenProjectRestoreState>("save_open_project_restore_order", {
|
||||||
|
request: { orderedProjectIds },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
readOpenProjectRestoreState(): Promise<OpenProjectRestoreState> {
|
||||||
|
return this.http.invoke<OpenProjectRestoreState>("read_open_project_restore_state");
|
||||||
|
}
|
||||||
readProjectContext(projectId: string): Promise<string> {
|
readProjectContext(projectId: string): Promise<string> {
|
||||||
return this.http.invoke<string>("read_project_context", { projectId });
|
return this.http.invoke<string>("read_project_context", { projectId });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,6 +70,9 @@ import type {
|
|||||||
PluginWorkspaceTextFile,
|
PluginWorkspaceTextFile,
|
||||||
Project,
|
Project,
|
||||||
ProjectFileListing,
|
ProjectFileListing,
|
||||||
|
OpenProjectRestoreFailure,
|
||||||
|
OpenProjectRestoreState,
|
||||||
|
RestoreOpenProjectsOutput,
|
||||||
JsonValue,
|
JsonValue,
|
||||||
ProjectMcpToolPermissions,
|
ProjectMcpToolPermissions,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
@ -1247,6 +1250,29 @@ export class MockTerminalGateway implements TerminalGateway {
|
|||||||
export class MockProjectGateway implements ProjectGateway {
|
export class MockProjectGateway implements ProjectGateway {
|
||||||
private projects: Project[] = [];
|
private projects: Project[] = [];
|
||||||
private contexts = new Map<string, string>();
|
private contexts = new Map<string, string>();
|
||||||
|
private restoreProjectIds: string[] = [];
|
||||||
|
private restoreFailures = new Map<string, OpenProjectRestoreFailure>();
|
||||||
|
|
||||||
|
/** 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<OpenProjectRestoreFailure, "projectId" | "position"> | 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<Project[]> {
|
async listProjects(): Promise<Project[]> {
|
||||||
return [...this.projects];
|
return [...this.projects];
|
||||||
@ -1283,7 +1309,77 @@ export class MockProjectGateway implements ProjectGateway {
|
|||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeProject(): Promise<void> {}
|
async closeProject(projectId: string): Promise<void> {
|
||||||
|
this.restoreProjectIds = this.restoreProjectIds.filter((id) => id !== projectId);
|
||||||
|
this.restoreFailures.delete(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreOpenProjects(): Promise<RestoreOpenProjectsOutput> {
|
||||||
|
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<RestoreOpenProjectsOutput> {
|
||||||
|
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<OpenProjectRestoreState> {
|
||||||
|
this.restoreProjectIds = this.restoreProjectIds.filter((id) => id !== projectId);
|
||||||
|
this.restoreFailures.delete(projectId);
|
||||||
|
return this.readOpenProjectRestoreState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveOpenProjectRestoreOrder(
|
||||||
|
orderedProjectIds: string[],
|
||||||
|
): Promise<OpenProjectRestoreState> {
|
||||||
|
const retained = this.restoreProjectIds.filter(
|
||||||
|
(id) => !orderedProjectIds.includes(id),
|
||||||
|
);
|
||||||
|
this.restoreProjectIds = [...orderedProjectIds, ...retained];
|
||||||
|
return this.readOpenProjectRestoreState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async readOpenProjectRestoreState(): Promise<OpenProjectRestoreState> {
|
||||||
|
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<string> {
|
async readProjectContext(projectId: string): Promise<string> {
|
||||||
if (!this.projects.some((p) => p.id === projectId)) {
|
if (!this.projects.some((p) => p.id === projectId)) {
|
||||||
|
|||||||
@ -24,4 +24,33 @@ describe("TauriProjectGateway invoke payloads", () => {
|
|||||||
request: { projectId: "proj-1", content: "# next" },
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,7 +8,12 @@
|
|||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
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";
|
import type { ProjectGateway } from "@/ports";
|
||||||
|
|
||||||
export class TauriProjectGateway implements ProjectGateway {
|
export class TauriProjectGateway implements ProjectGateway {
|
||||||
@ -28,6 +33,34 @@ export class TauriProjectGateway implements ProjectGateway {
|
|||||||
await invoke("close_project", { projectId });
|
await invoke("close_project", { projectId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput> {
|
||||||
|
return invoke<RestoreOpenProjectsOutput>("restore_open_projects");
|
||||||
|
}
|
||||||
|
|
||||||
|
retryOpenProjectRestore(projectId?: string): Promise<RestoreOpenProjectsOutput> {
|
||||||
|
return invoke<RestoreOpenProjectsOutput>("retry_open_project_restore", {
|
||||||
|
request: projectId === undefined ? {} : { projectId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeProjectFromRestore(projectId: string): Promise<OpenProjectRestoreState> {
|
||||||
|
return invoke<OpenProjectRestoreState>("remove_project_from_restore", {
|
||||||
|
request: { projectId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveOpenProjectRestoreOrder(
|
||||||
|
orderedProjectIds: string[],
|
||||||
|
): Promise<OpenProjectRestoreState> {
|
||||||
|
return invoke<OpenProjectRestoreState>("save_open_project_restore_order", {
|
||||||
|
request: { orderedProjectIds },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
readOpenProjectRestoreState(): Promise<OpenProjectRestoreState> {
|
||||||
|
return invoke<OpenProjectRestoreState>("read_open_project_restore_state");
|
||||||
|
}
|
||||||
|
|
||||||
readProjectContext(projectId: string): Promise<string> {
|
readProjectContext(projectId: string): Promise<string> {
|
||||||
return invoke<string>("read_project_context", { projectId });
|
return invoke<string>("read_project_context", { projectId });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -606,6 +606,43 @@ export interface Project {
|
|||||||
createdAt: number;
|
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`. */
|
/** Stable error shape mirrored from the backend `ErrorDto`. */
|
||||||
export interface GatewayError {
|
export interface GatewayError {
|
||||||
code: string;
|
code: string;
|
||||||
|
|||||||
@ -42,7 +42,12 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import type { Agent, DomainEvent, LayoutInfo } from "@/domain";
|
import type {
|
||||||
|
Agent,
|
||||||
|
DomainEvent,
|
||||||
|
LayoutInfo,
|
||||||
|
OpenProjectRestoreFailure,
|
||||||
|
} from "@/domain";
|
||||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||||
import { ConversationViewer } from "@/features/conversations";
|
import { ConversationViewer } from "@/features/conversations";
|
||||||
import {
|
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() {
|
export function ProjectsView() {
|
||||||
const vm = useProjects();
|
const vm = useProjects();
|
||||||
const projectActivity = useProjectActivity(vm.openTabs.map((tab) => tab.id));
|
const projectActivity = useProjectActivity(vm.openTabs.map((tab) => tab.id));
|
||||||
@ -247,10 +265,17 @@ export function ProjectsView() {
|
|||||||
useState<PendingConversationOpen | null>(null);
|
useState<PendingConversationOpen | null>(null);
|
||||||
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
|
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
|
||||||
const [detachErrorPanel, setDetachErrorPanel] = useState<PanelId | null>(null);
|
const [detachErrorPanel, setDetachErrorPanel] = useState<PanelId | null>(null);
|
||||||
|
const [restoreDetailsOpen, setRestoreDetailsOpen] = useState(false);
|
||||||
|
|
||||||
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||||||
const activeViewerConversation =
|
const activeViewerConversation =
|
||||||
viewerConversation?.projectId === active?.id ? viewerConversation : null;
|
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 projectNameById = useMemo(() => {
|
||||||
const names = new Map<string, string>();
|
const names = new Map<string, string>();
|
||||||
for (const project of vm.projects) names.set(project.id, project.name);
|
for (const project of vm.projects) names.set(project.id, project.name);
|
||||||
@ -1021,12 +1046,77 @@ export function ProjectsView() {
|
|||||||
</FloatingWindow>
|
</FloatingWindow>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* ── Background-task toasts (above floating windows) ── */}
|
<FloatingWindow
|
||||||
{taskToasts.length > 0 && (
|
open={restoreDetailsOpen && vm.restoreFailures.length > 0}
|
||||||
|
title="Projets non rouverts"
|
||||||
|
size="md"
|
||||||
|
onClose={() => setRestoreDetailsOpen(false)}
|
||||||
|
>
|
||||||
|
<ul className="flex flex-col gap-3" aria-label="restore failures">
|
||||||
|
{vm.restoreFailures.map((failure) => (
|
||||||
|
<li
|
||||||
|
key={failure.projectId}
|
||||||
|
className="rounded-md border border-border bg-raised p-3"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-content">{failure.name}</div>
|
||||||
|
<div className="mt-1 break-all text-xs text-muted">{failure.root}</div>
|
||||||
|
<p className="mt-2 text-sm text-muted">{restoreFailureCause(failure)}</p>
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
loading={vm.busy}
|
||||||
|
onClick={() => void vm.removeFromRestore(failure.projectId)}
|
||||||
|
>
|
||||||
|
Retirer de la reprise
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</FloatingWindow>
|
||||||
|
|
||||||
|
{/* ── Non-blocking notifications (above floating windows) ── */}
|
||||||
|
{(showRestoreNotification || taskToasts.length > 0) && (
|
||||||
<div
|
<div
|
||||||
className="fixed bottom-4 right-4 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
|
className="fixed bottom-4 right-4 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
|
||||||
style={{ zIndex: zIndex.toast }}
|
style={{ zIndex: zIndex.toast }}
|
||||||
>
|
>
|
||||||
|
{showRestoreNotification && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="rounded-md border border-border bg-surface px-3 py-2 shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium text-content">
|
||||||
|
{vm.restoreFailures.length === 1
|
||||||
|
? "1 projet n’a pas pu être rouvert"
|
||||||
|
: `${vm.restoreFailures.length} projets n’ont pas pu être rouverts`}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="Fermer la notification"
|
||||||
|
onClick={vm.dismissRestoreNotification}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setRestoreDetailsOpen(true)}>
|
||||||
|
Voir les détails
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
loading={vm.busy}
|
||||||
|
onClick={() => void vm.retryRestore()}
|
||||||
|
>
|
||||||
|
Réessayer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{taskToasts.map((toast) => (
|
{taskToasts.map((toast) => (
|
||||||
<button
|
<button
|
||||||
key={toast.id}
|
key={toast.id}
|
||||||
|
|||||||
@ -95,6 +95,135 @@ function openMenuItem(menu: string, item: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ProjectsView (with MockProjectGateway)", () => {
|
describe("ProjectsView (with MockProjectGateway)", () => {
|
||||||
|
it("silently restores retained projects in their persisted order", async () => {
|
||||||
|
const project = new MockProjectGateway();
|
||||||
|
const alpha = await project.createProject("alpha", "/p/a");
|
||||||
|
const beta = await project.createProject("beta", "/p/b");
|
||||||
|
project.setOpenProjectRestoreOrder([beta.id, alpha.id]);
|
||||||
|
|
||||||
|
renderView(project);
|
||||||
|
|
||||||
|
await waitFor(() => expect(within(tablist()).getAllByRole("tab")).toHaveLength(2));
|
||||||
|
const tabs = within(tablist()).getAllByRole("tab");
|
||||||
|
expect(tabs.map((tab) => tab.textContent?.replace("×", "").trim())).toEqual([
|
||||||
|
"beta",
|
||||||
|
"alpha",
|
||||||
|
]);
|
||||||
|
expect(screen.queryByRole("status")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows one restore warning, details its cause, and retries at the retained position", async () => {
|
||||||
|
const project = new MockProjectGateway();
|
||||||
|
const alpha = await project.createProject("alpha", "/p/a");
|
||||||
|
const beta = await project.createProject("beta", "/missing/b");
|
||||||
|
const gamma = await project.createProject("gamma", "/p/c");
|
||||||
|
project.setOpenProjectRestoreOrder([alpha.id, beta.id, gamma.id]);
|
||||||
|
project.setOpenProjectRestoreFailure(beta.id, {
|
||||||
|
name: beta.name,
|
||||||
|
root: beta.root,
|
||||||
|
code: "inaccessible",
|
||||||
|
message: "directory metadata failed",
|
||||||
|
});
|
||||||
|
|
||||||
|
renderView(project);
|
||||||
|
|
||||||
|
expect(await screen.findByText("1 projet n’a pas pu être rouvert")).toBeTruthy();
|
||||||
|
expect(screen.getAllByRole("status")).toHaveLength(1);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Voir les détails" }));
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: "Projets non rouverts" });
|
||||||
|
expect(within(dialog).getByText("beta")).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText("/missing/b")).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText("Le chemin est introuvable ou inaccessible.")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "close Projets non rouverts" }));
|
||||||
|
project.setOpenProjectRestoreFailure(beta.id, null);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Réessayer" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(within(tablist()).getAllByRole("tab")).toHaveLength(3));
|
||||||
|
expect(
|
||||||
|
within(tablist())
|
||||||
|
.getAllByRole("tab")
|
||||||
|
.map((tab) => tab.textContent?.replace("×", "").trim()),
|
||||||
|
).toEqual(["alpha", "beta", "gamma"]);
|
||||||
|
expect(screen.queryByText("1 projet n’a pas pu être rouvert")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a failed project from future restore attempts", async () => {
|
||||||
|
const project = new MockProjectGateway();
|
||||||
|
const alpha = await project.createProject("alpha", "/missing/a");
|
||||||
|
project.setOpenProjectRestoreOrder([alpha.id]);
|
||||||
|
project.setOpenProjectRestoreFailure(alpha.id, {
|
||||||
|
name: alpha.name,
|
||||||
|
root: alpha.root,
|
||||||
|
code: "notFound",
|
||||||
|
message: "missing registry entry",
|
||||||
|
});
|
||||||
|
renderView(project);
|
||||||
|
|
||||||
|
await screen.findByText("1 projet n’a pas pu être rouvert");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Voir les détails" }));
|
||||||
|
fireEvent.click(
|
||||||
|
within(await screen.findByRole("dialog", { name: "Projets non rouverts" })).getByRole(
|
||||||
|
"button",
|
||||||
|
{ name: "Retirer de la reprise" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByText("Projets non rouverts")).toBeNull());
|
||||||
|
expect((await project.readOpenProjectRestoreState()).entries).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the plural restore warning without duplicating the launch notification", async () => {
|
||||||
|
const project = new MockProjectGateway();
|
||||||
|
const alpha = await project.createProject("alpha", "/missing/a");
|
||||||
|
const beta = await project.createProject("beta", "/missing/b");
|
||||||
|
project.setOpenProjectRestoreOrder([alpha.id, beta.id]);
|
||||||
|
for (const retained of [alpha, beta]) {
|
||||||
|
project.setOpenProjectRestoreFailure(retained.id, {
|
||||||
|
name: retained.name,
|
||||||
|
root: retained.root,
|
||||||
|
code: "inaccessible",
|
||||||
|
message: "offline volume",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderView(project);
|
||||||
|
|
||||||
|
expect(await screen.findByText("2 projets n’ont pas pu être rouverts")).toBeTruthy();
|
||||||
|
expect(screen.getAllByRole("status")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists an explicit close by going through the backend before removing the tab", async () => {
|
||||||
|
const project = new MockProjectGateway();
|
||||||
|
const alpha = await project.createProject("alpha", "/p/a");
|
||||||
|
project.setOpenProjectRestoreOrder([alpha.id]);
|
||||||
|
renderView(project);
|
||||||
|
|
||||||
|
await screen.findByRole("tab", { name: "alpha" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("No open tabs.")).toBeTruthy());
|
||||||
|
expect((await project.readOpenProjectRestoreState()).entries).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a tab open when its backend close fails", async () => {
|
||||||
|
const project = new MockProjectGateway();
|
||||||
|
const alpha = await project.createProject("alpha", "/p/a");
|
||||||
|
project.setOpenProjectRestoreOrder([alpha.id]);
|
||||||
|
project.closeProject = async () => {
|
||||||
|
throw { code: "STORE", message: "could not persist close" };
|
||||||
|
};
|
||||||
|
renderView(project);
|
||||||
|
|
||||||
|
await screen.findByRole("tab", { name: "alpha" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
|
||||||
|
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||||
|
"could not persist close",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("tab", { name: "alpha" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("creating a project adds it to the known list and opens a tab", async () => {
|
it("creating a project adds it to the known list and opens a tab", async () => {
|
||||||
renderView();
|
renderView();
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,13 @@
|
|||||||
* testable with a mock gateway (ARCHITECTURE §1.3).
|
* testable with a mock gateway (ARCHITECTURE §1.3).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { GatewayError, Project } from "@/domain";
|
import type {
|
||||||
|
GatewayError,
|
||||||
|
OpenProjectRestoreFailure,
|
||||||
|
Project,
|
||||||
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
/** What the projects UI needs from this hook. */
|
/** What the projects UI needs from this hook. */
|
||||||
@ -24,6 +28,10 @@ export interface ProjectsViewModel {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
/** Whether a request is in flight. */
|
/** Whether a request is in flight. */
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
/** Retained projects that could not be restored during this launch/retry. */
|
||||||
|
restoreFailures: OpenProjectRestoreFailure[];
|
||||||
|
/** Whether the launch-only non-blocking failure notification is visible. */
|
||||||
|
restoreNotificationVisible: boolean;
|
||||||
/** Reloads the known-projects list from the registry. */
|
/** Reloads the known-projects list from the registry. */
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
/** Creates a project, refreshes the list and opens it as a tab. */
|
/** Creates a project, refreshes the list and opens it as a tab. */
|
||||||
@ -32,6 +40,11 @@ export interface ProjectsViewModel {
|
|||||||
openProject: (projectId: string) => Promise<void>;
|
openProject: (projectId: string) => Promise<void>;
|
||||||
/** Closes a tab (does not delete the project from the registry). */
|
/** Closes a tab (does not delete the project from the registry). */
|
||||||
closeTab: (projectId: string) => Promise<void>;
|
closeTab: (projectId: string) => Promise<void>;
|
||||||
|
/** Retries one failed restore, or every failure when omitted. */
|
||||||
|
retryRestore: (projectId?: string) => Promise<void>;
|
||||||
|
/** Removes one failure from the persisted next-launch restore set. */
|
||||||
|
removeFromRestore: (projectId: string) => Promise<void>;
|
||||||
|
dismissRestoreNotification: () => void;
|
||||||
/** Activates an already-open tab. */
|
/** Activates an already-open tab. */
|
||||||
activateTab: (projectId: string) => void;
|
activateTab: (projectId: string) => void;
|
||||||
}
|
}
|
||||||
@ -50,6 +63,9 @@ export function useProjects(): ProjectsViewModel {
|
|||||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [restoreFailures, setRestoreFailures] = useState<OpenProjectRestoreFailure[]>([]);
|
||||||
|
const [restoreNotificationVisible, setRestoreNotificationVisible] = useState(false);
|
||||||
|
const initializationStarted = useRef(false);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@ -64,8 +80,37 @@ export function useProjects(): ProjectsViewModel {
|
|||||||
}, [project]);
|
}, [project]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
if (initializationStarted.current) return;
|
||||||
}, [refresh]);
|
initializationStarted.current = true;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
void Promise.allSettled([
|
||||||
|
project.listProjects(),
|
||||||
|
project.restoreOpenProjects(),
|
||||||
|
]).then(([listed, restored]) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (listed.status === "fulfilled") setProjects(listed.value);
|
||||||
|
else errors.push(describe(listed.reason));
|
||||||
|
if (restored.status === "fulfilled") {
|
||||||
|
setOpenTabs((current) => {
|
||||||
|
const restoredIds = new Set(restored.value.opened.map((entry) => entry.id));
|
||||||
|
return [
|
||||||
|
...restored.value.opened,
|
||||||
|
...current.filter((entry) => !restoredIds.has(entry.id)),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
setActiveTabId(
|
||||||
|
(current) => current ?? restored.value.opened.at(-1)?.id ?? null,
|
||||||
|
);
|
||||||
|
setRestoreFailures(restored.value.failures);
|
||||||
|
setRestoreNotificationVisible(restored.value.failures.length > 0);
|
||||||
|
} else {
|
||||||
|
errors.push(describe(restored.reason));
|
||||||
|
}
|
||||||
|
setError(errors.length > 0 ? errors.join(" · ") : null);
|
||||||
|
setBusy(false);
|
||||||
|
});
|
||||||
|
}, [project]);
|
||||||
|
|
||||||
const addTab = useCallback((p: Project) => {
|
const addTab = useCallback((p: Project) => {
|
||||||
setOpenTabs((prev) =>
|
setOpenTabs((prev) =>
|
||||||
@ -114,6 +159,7 @@ export function useProjects(): ProjectsViewModel {
|
|||||||
await project.closeProject(projectId);
|
await project.closeProject(projectId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(describe(e));
|
setError(describe(e));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setOpenTabs((prev) => {
|
setOpenTabs((prev) => {
|
||||||
const next = prev.filter((t) => t.id !== projectId);
|
const next = prev.filter((t) => t.id !== projectId);
|
||||||
@ -126,6 +172,62 @@ export function useProjects(): ProjectsViewModel {
|
|||||||
[project],
|
[project],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const retryRestore = useCallback(
|
||||||
|
async (projectId?: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const positions = new Map(
|
||||||
|
restoreFailures.map((failure) => [failure.projectId, failure.position]),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const result = await project.retryOpenProjectRestore(projectId);
|
||||||
|
setOpenTabs((previous) => {
|
||||||
|
const next = [...previous];
|
||||||
|
for (const opened of [...result.opened].sort(
|
||||||
|
(a, b) => (positions.get(a.id) ?? next.length) - (positions.get(b.id) ?? next.length),
|
||||||
|
)) {
|
||||||
|
if (next.some((tab) => tab.id === opened.id)) continue;
|
||||||
|
const position = positions.get(opened.id) ?? next.length;
|
||||||
|
next.splice(Math.min(position, next.length), 0, opened);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setActiveTabId((current) => current ?? result.opened.at(-1)?.id ?? null);
|
||||||
|
setRestoreFailures((previous) =>
|
||||||
|
projectId === undefined
|
||||||
|
? result.failures
|
||||||
|
: [
|
||||||
|
...previous.filter((failure) => failure.projectId !== projectId),
|
||||||
|
...result.failures,
|
||||||
|
].sort((a, b) => a.position - b.position),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[project, restoreFailures],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeFromRestore = useCallback(
|
||||||
|
async (projectId: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await project.removeProjectFromRestore(projectId);
|
||||||
|
setRestoreFailures((previous) =>
|
||||||
|
previous.filter((failure) => failure.projectId !== projectId),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[project],
|
||||||
|
);
|
||||||
|
|
||||||
const activateTab = useCallback((projectId: string) => {
|
const activateTab = useCallback((projectId: string) => {
|
||||||
setActiveTabId(projectId);
|
setActiveTabId(projectId);
|
||||||
}, []);
|
}, []);
|
||||||
@ -136,10 +238,15 @@ export function useProjects(): ProjectsViewModel {
|
|||||||
activeTabId,
|
activeTabId,
|
||||||
error,
|
error,
|
||||||
busy,
|
busy,
|
||||||
|
restoreFailures,
|
||||||
|
restoreNotificationVisible,
|
||||||
refresh,
|
refresh,
|
||||||
createProject,
|
createProject,
|
||||||
openProject,
|
openProject,
|
||||||
closeTab,
|
closeTab,
|
||||||
|
retryRestore,
|
||||||
|
removeFromRestore,
|
||||||
|
dismissRestoreNotification: () => setRestoreNotificationVisible(false),
|
||||||
activateTab,
|
activateTab,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,6 +63,11 @@ function gateways(overrides: {
|
|||||||
createProject: vi.fn(),
|
createProject: vi.fn(),
|
||||||
openProject: vi.fn(),
|
openProject: vi.fn(),
|
||||||
closeProject: vi.fn(),
|
closeProject: vi.fn(),
|
||||||
|
restoreOpenProjects: vi.fn(async () => ({ opened: [], failures: [] })),
|
||||||
|
retryOpenProjectRestore: vi.fn(async () => ({ opened: [], failures: [] })),
|
||||||
|
removeProjectFromRestore: vi.fn(async () => ({ version: 1, entries: [], updatedAt: 0 })),
|
||||||
|
saveOpenProjectRestoreOrder: vi.fn(async () => ({ version: 1, entries: [], updatedAt: 0 })),
|
||||||
|
readOpenProjectRestoreState: vi.fn(async () => ({ version: 1, entries: [], updatedAt: 0 })),
|
||||||
readProjectContext: vi.fn(async () => "project context"),
|
readProjectContext: vi.fn(async () => "project context"),
|
||||||
updateProjectContext: vi.fn(),
|
updateProjectContext: vi.fn(),
|
||||||
listProjectFiles: vi.fn(async (_projectId, path) => ({ path, entries: [] })),
|
listProjectFiles: vi.fn(async (_projectId, path) => ({ path, entries: [] })),
|
||||||
|
|||||||
@ -70,6 +70,8 @@ import type {
|
|||||||
ProjectMcpToolPermissions,
|
ProjectMcpToolPermissions,
|
||||||
PageDirection,
|
PageDirection,
|
||||||
Project,
|
Project,
|
||||||
|
OpenProjectRestoreState,
|
||||||
|
RestoreOpenProjectsOutput,
|
||||||
ProjectFileListing,
|
ProjectFileListing,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
ProjectWorkState,
|
ProjectWorkState,
|
||||||
@ -577,6 +579,16 @@ export interface ProjectGateway {
|
|||||||
openProject(projectId: string): Promise<Project>;
|
openProject(projectId: string): Promise<Project>;
|
||||||
/** Closes a project by id. */
|
/** Closes a project by id. */
|
||||||
closeProject(projectId: string): Promise<void>;
|
closeProject(projectId: string): Promise<void>;
|
||||||
|
/** Restores the projects retained at the end of the previous app run. */
|
||||||
|
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput>;
|
||||||
|
/** Retries one retained restore failure, or every failure when omitted. */
|
||||||
|
retryOpenProjectRestore(projectId?: string): Promise<RestoreOpenProjectsOutput>;
|
||||||
|
/** Stops retaining one failed project for later launches. */
|
||||||
|
removeProjectFromRestore(projectId: string): Promise<OpenProjectRestoreState>;
|
||||||
|
/** Persists the visible order while retaining omitted tombstones. */
|
||||||
|
saveOpenProjectRestoreOrder(orderedProjectIds: string[]): Promise<OpenProjectRestoreState>;
|
||||||
|
/** Reads the machine-local ordered restore state. */
|
||||||
|
readOpenProjectRestoreState(): Promise<OpenProjectRestoreState>;
|
||||||
/** Reads the shared project context stored at `.ideai/CONTEXT.md`. */
|
/** Reads the shared project context stored at `.ideai/CONTEXT.md`. */
|
||||||
readProjectContext(projectId: string): Promise<string>;
|
readProjectContext(projectId: string): Promise<string>;
|
||||||
/** Overwrites the shared project context stored at `.ideai/CONTEXT.md`. */
|
/** Overwrites the shared project context stored at `.ideai/CONTEXT.md`. */
|
||||||
|
|||||||
Reference in New Issue
Block a user