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

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

View File

@ -22,16 +22,15 @@ use application::{
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput,
ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions,
LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput,
LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput,
ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput,
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput,
SetAgentMcpToolAllowedInput, SlashCommandEffect, SnapshotRunningAgentsInput,
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput,
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput,
RenameLayoutInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput,
ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SaveChatDraftInput,
SetActiveLayoutInput, SetAgentMcpToolAllowedInput, SlashCommandEffect,
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput,
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
UpdateProjectMcpToolPermissionsInput, UpdateProjectOrchestratorInput,
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
@ -65,20 +64,22 @@ use crate::dto::{
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, ManagedTempGcResponseDto,
ManagedTempHandleRequestDto, ManagedTempLeaseDto, ManagedTempLeaseResponseDto, MemoryDto,
MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto,
OpenCodeProviderListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto,
ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadChatDraftResponseDto,
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto,
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto,
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto,
SkillDto, SkillListDto, SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto,
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto,
RecallMemoryRequestDto, RemoveOpenProjectRestoreEntryRequestDto, RenameLayoutRequestDto,
ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto,
ResolvedAgentSystemPermissionsDto, RestoreOpenProjectsOutputDto, ResumableAgentListDto,
RetryOpenProjectRestoreRequestDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto,
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto,
SaveOpenProjectRestoreOrderRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto,
SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
@ -438,9 +439,12 @@ pub async fn create_project(
.execute(request.into())
.await
.map_err(ErrorDto::from)?;
// Start tailing this project's `.ideai/requests/` tree so an orchestrator
// agent can delegate agent/skill creation to IdeA (§14.3).
state.ensure_orchestrator_watch(&output.project);
state
.record_project_opened_for_restore
.execute(output.project.clone())
.await
.map_err(ErrorDto::from)?;
state.activate_project(&output.project).await;
Ok(ProjectDto::from(output))
}
@ -467,36 +471,9 @@ pub(crate) async fn open_project_for_adapter(
) -> Result<ProjectDto, ErrorDto> {
let id = parse_project_id(&project_id)?;
let output = state
.open_project
.execute(OpenProjectInput { project_id: id })
.open_project_and_activate(id)
.await
.map_err(ErrorDto::from)?;
// R0c (§3.4 « Trou C ») : dé-doublonne les `layouts.json` portant plusieurs
// feuilles sur le même agent AVANT toute reprise (`list_resumable_agents`
// relit la version persistée), de sorte qu'on ne propose / ne relance qu'une
// session par agent. Idempotent (no-op sans doublon) et best-effort : un échec
// ne doit pas bloquer l'ouverture.
let _ = state
.reconcile_layouts
.execute(ReconcileLayoutsInput { project_id: id })
.await;
// Réconcilie les lignes de live-state fantômes : un crash ne déclenche jamais
// `close_project`, donc l'ouverture est le seul filet fiable pour repasser en
// `idle` les agents `working`/`waiting`/`blocked` dont la session est morte.
// Acte système (appelle le port live-state directement, jamais via la surface
// MCP self-only). Best-effort : un échec ne bloque pas l'ouverture. Tourne
// AVANT toute relance d'agent (toute reprise réécrit ensuite en LWW).
let _ = state
.reconcile_live_state
.execute(ReconcileLiveStateInput { project_id: id })
.await;
// Réconcilie les tâches de fond persistées au même point de boot que le
// live-state : les Running sans handle vivant deviennent terminales, les
// complétions WakeOwner non livrées repassent dans l'inbox unifiée.
let _ = state.reconcile_background_tasks.execute(id).await;
// (Re)start the orchestrator watcher for this project (idempotent, §14.3).
state.ensure_orchestrator_watch(&output.project);
state.reconcile_claude_run_dirs(&output.project).await;
Ok(ProjectDto::from(output))
}
@ -633,10 +610,92 @@ pub async fn close_project(project_id: String, state: State<'_, AppState>) -> Re
workspace: None,
})
.await
.map_err(ErrorDto::from)?;
state
.record_project_closed_for_restore
.execute(id)
.await
.map(|_| ())
.map_err(ErrorDto::from)
}
/// Reads the ordered open-project restore state without opening projects.
#[tauri::command]
pub async fn read_open_project_restore_state(
state: State<'_, AppState>,
) -> Result<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.
///
/// # Errors

View File

@ -254,6 +254,11 @@ pub fn run() {
commands::create_project,
commands::open_project,
commands::close_project,
commands::read_open_project_restore_state,
commands::restore_open_projects,
commands::retry_open_project_restore,
commands::remove_project_from_restore,
commands::save_open_project_restore_order,
commands::list_projects,
commands::read_project_context,
commands::update_project_context,

View File

@ -204,10 +204,13 @@ pub use plugin::{
pub use project::{
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
CreateProjectInput, CreateProjectOutput, ListProjectFiles, ListProjectFilesInput, ListProjects,
ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput, ProjectFileEntry,
ProjectFileKind, ProjectFileListing, ProjectMeta, ReadProjectContext, ReadProjectContextInput,
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
PROJECT_CONTEXT_FILE,
ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,
OpenProjectRestoreService, ProjectFileEntry, ProjectFileKind, ProjectFileListing, ProjectMeta,
ReadOpenProjectRestoreState, ReadProjectContext, ReadProjectContextInput,
ReadProjectContextOutput, RecordProjectClosedForRestore, RecordProjectOpenedForRestore,
RemoveProjectFromRestore, RestoreOpenProjectFailure, RestoreOpenProjects,
RestoreOpenProjectsOutput, RetryOpenProjectRestore, SaveOpenProjectRestoreOrder,
UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
};
pub use project_plugin::{
plugin_skill_ref, plugin_tool_ref, AssignmentMutation, ListProjectPluginSettings,

View File

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

View File

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

View File

@ -17,8 +17,8 @@ use application::{
CreateProjectInput, CreateProjectOutput, ExecuteSlashCommandOutput, GitGraphOutput,
HealthInput, HealthReport, LayoutKind, ListProjectsOutput, ListSlashCommandsOutput,
LiveRuntimeState, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState,
SlashCommandEffect, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage,
TurnSource, TurnView,
RestoreOpenProjectFailure, RestoreOpenProjectsOutput, SlashCommandEffect, StopLiveAgentOutput,
TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView,
};
use domain::ports::{ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage};
use domain::{
@ -1148,6 +1148,110 @@ impl From<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.
///
/// # Errors

View File

@ -44,14 +44,16 @@ use application::{
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProjectPluginResolver, ProposeContext,
PurgeManagedTemp, QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext,
ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions,
ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory,
ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers,
ReadMemory, ReadMemoryIndex, ReadOpenProjectRestoreState, ReadProjectContext, ReadSkill,
ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput,
ReconcilePluginMcpServers, RecordProjectClosedForRestore, RecordProjectOpenedForRestore,
RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin,
ReloadPluginInput, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions,
ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenProjects, RestoreOpenProjectsOutput,
RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore, ReviewPluginPackage,
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, SaveEmbedderProfile,
SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder, SaveProfile,
SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled,
SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
@ -72,12 +74,13 @@ use domain::ports::{
BackgroundTaskStore, ChatAttachmentStore, ChatDraftStore, Clock, DeviceSessionStore, Embedder,
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus,
FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, ManagedTempStore,
McpToolPermissionStore, MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore,
PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore,
PluginStorageStore, ProcessSpawner, ProfileStore, ProjectPluginSettingsStore, ProjectStore,
PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore,
SprintStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore,
ToolInvoker, WakeError, WakeReason, WindowStateStore,
McpToolPermissionStore, MemoryRecall, MemoryStore, ModelArtifactDownloader,
OpenProjectRestoreStore, PermissionStore, PluginManifestValidator, PluginMcpSupervisor,
PluginPackageStore, PluginRegistryStore, PluginStorageStore, ProcessSpawner, ProfileStore,
ProjectPluginSettingsStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe,
ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
WakeError, WakeReason, WindowStateStore,
};
use domain::profile::{
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
@ -100,20 +103,21 @@ use infrastructure::{
FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsChatDraftStore,
FsConversationLog, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore,
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore,
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher,
FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
FsProfileStore, FsProjectPluginSettingsStore, FsProjectStore, FsProviderSessionStore,
FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore,
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner,
ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard,
StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider,
TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore,
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectStore,
FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore,
FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer,
HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue,
IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter,
LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess,
LocalProcessSpawner, ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
};
pub mod dto;
@ -1789,6 +1793,20 @@ pub struct BackendCore {
pub open_project: Arc<OpenProject>,
/// Close a project (persist state).
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.
pub close_tab: Arc<CloseTab>,
/// List known projects.
@ -2342,6 +2360,10 @@ impl BackendCore {
Arc::clone(&fs) as Arc<dyn FileSystem>,
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(
Arc::clone(&fs) as Arc<dyn FileSystem>,
app_data_dir.to_string_lossy().into_owned(),
@ -2357,6 +2379,8 @@ impl BackendCore {
// Port-typed handles for injection.
let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
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 device_session_port = Arc::clone(&device_session_store) as Arc<dyn DeviceSessionStore>;
let pair_attempt_limiter_port =
@ -2429,6 +2453,33 @@ impl BackendCore {
Arc::clone(&fs_port),
));
let close_project = Arc::new(CloseProject::new(Arc::clone(&store_port)));
let open_project_restore_service = Arc::new(application::OpenProjectRestoreService::new(
Arc::clone(&open_project_restore_store_port),
Arc::clone(&store_port),
Arc::clone(&fs_port),
Arc::clone(&clock) as Arc<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 list_projects = Arc::new(ListProjects::new(Arc::clone(&store_port)));
let list_project_files = Arc::new(ListProjectFiles::new(Arc::clone(&fs_port)));
@ -4017,6 +4068,13 @@ impl BackendCore {
create_project,
open_project,
close_project,
read_open_project_restore_state,
record_project_opened_for_restore,
record_project_closed_for_restore,
save_open_project_restore_order,
restore_open_projects,
retry_open_project_restore,
remove_project_from_restore,
close_tab,
list_projects,
list_project_files,
@ -4216,6 +4274,63 @@ impl BackendCore {
}
}
/// Opens one project, persists its visible-open intent, then applies the
/// complete activation side-effect sequence shared by every driving adapter.
pub async fn open_project_and_activate(
&self,
project_id: ProjectId,
) -> Result<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
/// running for it (idempotent). The watcher tails the project's
/// `.ideai/requests/` tree and dispatches each request through the shared
@ -8168,3 +8283,166 @@ mod bind_endpoint_d1_tests {
);
}
}
#[cfg(test)]
mod open_project_restore_tests {
use super::BackendCore;
use application::CreateProjectInput;
use domain::{OpenProjectRestoreFailureCode, Project, ProjectId, ProjectPath, RemoteRef};
use std::path::{Path, PathBuf};
use uuid::Uuid;
fn temp_path(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("idea-restore-{tag}-{}", Uuid::new_v4()))
}
async fn create_project(core: &BackendCore, root: &Path, name: &str) -> Project {
core.create_project
.execute(CreateProjectInput {
name: name.to_owned(),
root: root.to_string_lossy().into_owned(),
remote: None,
default_profile_id: None,
})
.await
.expect("project creation succeeds")
.project
}
#[tokio::test]
async fn restore_state_preserves_order_failures_retry_close_and_remove() {
let app_data = temp_path("appdata");
let roots = temp_path("roots");
let root_a = roots.join("a");
let root_b = roots.join("b");
let core = BackendCore::build(app_data.clone());
let empty = core
.restore_open_projects_and_activate()
.await
.expect("missing restore file is an empty successful restore");
assert!(empty.opened.is_empty());
assert!(empty.failures.is_empty());
assert!(!app_data.join("open-projects.json").exists());
let project_a = create_project(&core, &root_a, "A").await;
let project_b = create_project(&core, &root_b, "B").await;
core.record_project_opened_for_restore
.execute(project_a.clone())
.await
.expect("A is recorded");
core.record_project_opened_for_restore
.execute(project_b.clone())
.await
.expect("B is recorded");
let reordered = core
.save_open_project_restore_order
.execute(vec![project_b.id, project_a.id])
.await
.expect("order is saved");
assert_eq!(
reordered
.entries
.iter()
.map(|entry| entry.project_id)
.collect::<Vec<_>>(),
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);
}
}

View File

@ -62,6 +62,7 @@ pub mod plugin;
pub mod ports;
pub mod profile;
pub mod project;
pub mod project_restore;
pub mod readiness;
pub mod remote;
pub mod sandbox;
@ -88,6 +89,10 @@ pub use ids::{
};
pub use project::{Project, ProjectPath};
pub use project_restore::{
OpenProjectRestoreEntry, OpenProjectRestoreFailure, OpenProjectRestoreFailureCode,
OpenProjectRestoreState, OPEN_PROJECT_RESTORE_VERSION,
};
pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};

View File

@ -2307,6 +2307,21 @@ pub trait ProjectStore: Send + Sync {
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.
#[async_trait]
pub trait WindowStateStore: Send + Sync {

View 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;
}
}
}

View File

@ -119,9 +119,9 @@ pub use store::{
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
FsMcpToolPermissionStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
FsSecretStore, FsSkillStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore,
HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
FsMcpToolPermissionStore, FsMemoryStore, FsOpenProjectRestoreStore, FsPermissionStore,
FsProfileStore, FsProjectStore, FsSecretStore, FsSkillStore, FsSystemPermissionStore,
FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall,
OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
};

View File

@ -11,6 +11,7 @@ mod embedder;
mod live_state;
mod mcp_tool_permission;
mod memory;
mod open_project_restore;
mod permission;
mod profile;
mod project;
@ -37,6 +38,7 @@ pub use embedder::{
pub use live_state::FsLiveStateStore;
pub use mcp_tool_permission::FsMcpToolPermissionStore;
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
pub use open_project_restore::FsOpenProjectRestoreStore;
pub use permission::FsPermissionStore;
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
pub use project::FsProjectStore;

View 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()))
}
}

View File

@ -53,17 +53,16 @@ use application::{
PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput,
RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, SetActiveLayoutInput, SetAgentMcpToolAllowedInput,
StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContactableInput,
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
WriteToTerminalInput,
RenameDeviceInput, RenameLayoutInput, RenameSprintInput, ReorderSprintsInput,
ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput,
ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput,
SetAgentMcpToolAllowedInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput,
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
};
use domain::ports::{
PtyHandle, ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource,
@ -92,19 +91,21 @@ use backend::dto::{
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto,
OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadConversationPageRequestDto, RecallMemoryRequestDto, RenameLayoutRequestDto,
OpenProjectRestoreStateDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
RemoveOpenProjectRestoreEntryRequestDto, RenameLayoutRequestDto,
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto,
SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto,
SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto,
SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
RestoreOpenProjectsOutputDto, ResumableAgentListDto, RetryOpenProjectRestoreRequestDto,
SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
SaveOpenProjectRestoreOrderRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto,
SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto,
SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto,
TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto,
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
@ -3346,6 +3347,19 @@ async fn invoke(
"health" => invoke_health(&request.args, &state.app),
"list_projects" => invoke_list_projects(&state.app).await,
"open_project" => invoke_open_project(&request.args, &state.app).await,
"read_open_project_restore_state" => {
invoke_read_open_project_restore_state(&state.app).await
}
"restore_open_projects" => invoke_restore_open_projects(&state.app).await,
"retry_open_project_restore" => {
invoke_retry_open_project_restore(&request.args, &state.app).await
}
"remove_project_from_restore" => {
invoke_remove_project_from_restore(&request.args, &state.app).await
}
"save_open_project_restore_order" => {
invoke_save_open_project_restore_order(&request.args, &state.app).await
}
"load_layout" => invoke_load_layout(&request.args, &state.app).await,
"mutate_layout" => invoke_mutate_layout(&request.args, &state.app).await,
"list_layouts" => invoke_list_layouts(&request.args, &state.app).await,
@ -3544,24 +3558,89 @@ async fn open_project_for_web(
) -> Result<ProjectDto, ErrorDto> {
let id = parse_project_id(project_id)?;
let output = state
.open_project
.execute(OpenProjectInput { project_id: id })
.open_project_and_activate(id)
.await
.map_err(ErrorDto::from)?;
let _ = state
.reconcile_layouts
.execute(ReconcileLayoutsInput { project_id: id })
.await;
let _ = state
.reconcile_live_state
.execute(ReconcileLiveStateInput { project_id: id })
.await;
let _ = state.reconcile_background_tasks.execute(id).await;
state.ensure_orchestrator_watch(&output.project);
state.reconcile_claude_run_dirs(&output.project).await;
Ok(ProjectDto::from(output))
}
async fn invoke_read_open_project_restore_state(state: &BackendCore) -> Result<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> {
let request = required_request::<LoadLayoutInvokeArgs>("load_layout", args)?;
let project_id = parse_project_id(&request.project_id)?;
@ -9538,6 +9617,51 @@ mod tests {
.is_some_and(|root| root.contains("idea-server-project-")));
}
#[tokio::test]
async fn authorized_open_project_restore_commands_share_the_tauri_contract() {
let state = state();
let project_id = create_project_for_test(&state, "Restore Web").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let opened = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({ "command": "open_project", "args": { "projectId": project_id } }),
&[("cookie", &cookie)],
)
.await;
assert_eq!(response_json(opened).await.0, StatusCode::OK);
let restored = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({ "command": "restore_open_projects", "args": {} }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(restored).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["opened"][0]["id"], project_id);
assert_eq!(body["failures"], json!([]));
let removed = request(
state,
Method::POST,
"/api/invoke",
json!({
"command": "remove_project_from_restore",
"args": { "request": { "projectId": project_id } }
}),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(removed).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["entries"], json!([]));
}
#[tokio::test]
async fn authorized_open_project_returns_readonly_project_dto() {
let state = state();