feat(backend,domain,infrastructure,application,app-tauri,web-server,frontend): suppression d'un projet du registre IdeA sans toucher son dossier disque — #308 (QA verte)

cause: l'utilisateur ne pouvait pas retirer un projet du registre IdeA (projects.json) ; la seule issue laissait l'entrée fantôme dans la liste des projets, sans capacité générique de retrait.

domain: port ProjectStore::remove_project(id) -> Option<Project> avec implémentation par défaut « not supported » pour ne pas casser les stores spécialisés.

infrastructure: FsProjectStore implémente remove_project — retire l'entrée du registre persisté et la renvoie, None si id inconnu, ordre relatif du registre préservé ; test store dédié.

application: use case RemoveProjectFromRegistry (remove.rs) — NotFound explicite si id absent, retourne le projet retiré + registre rafraîchi.

backend/app-tauri/web-server: DTO RemoveProjectRequestDto/RemoveProjectResponseDto (removed, projects, restore_state, was_open), adapter partagé remove_project_for_adapter réutilisé par la commande Tauri remove_project et la route HTTP — même contrat desktop/web ; remove_project_and_deactivate désactive le projet s'il était ouvert, purge l'entrée open-projects restore correspondante et renvoie was_open/restore_state ; test adapter vérifiant le contrat de bout en bout (entrée retirée, restore purgé, root intact).

frontend: DTO + ProjectGateway.removeProject (HTTP + mock), ProjectsView expose un menu d'actions projet (ProjectActionsMenu) avec entrée Retirer, RemoveProjectDialog — confirmation explicite précisant que le dossier disque n'est pas supprimé, invalidation/rechargement de la liste des projets après succès (useProjects), tests ProjectsView/projects/adapters/httpInvoker/services.

QA verte: cargo targeted tests/check OK (domain, application project_usecases, infrastructure project_store, backend, app-tauri, web-server), npm targeted/typecheck/build OK ; suite npm test complète rouge non bloquante hors #308 (échecs préexistants listProjectSettings/web). Vérifications Git: git diff --check OK, cargo fmt --all -- --check OK.

fichiers .ideai/tickets/308/, tickets/counter.json, tickets/index.json, attachments/ et scripts/ hors périmètre et préservés en worktree (sync store traité séparément) ; aucun push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 18:27:15 +02:00
parent ca1a27a2b9
commit b9f8ed303e
25 changed files with 1009 additions and 29 deletions

View File

@ -48,16 +48,16 @@ use application::{
ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput,
ReconcilePluginMcpServers, RecordProjectClosedForRestore, RecordProjectOpenedForRestore,
RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin,
ReloadPluginInput, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions,
ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenProjects, RestoreOpenProjectsOutput,
RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore, ReviewPluginPackage,
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, SaveEmbedderProfile,
SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder, SaveProfile,
SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled,
ReloadPluginInput, RemoveProjectFromRegistry, RenameDevice, RenameLayout, RenameSprint,
ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions,
ResolveAgentSystemPermissions, 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,
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
SnapshotRunningAgentsInput, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode,
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions,
UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet,
@ -90,7 +90,7 @@ use domain::remote::RemoteKind;
use domain::{
AgentId, AgentInbox, BackgroundTask, BackgroundTaskResult, BackgroundTaskWakePolicy,
DomainEvent, EmbedderProfile, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus,
InboxSource, Project, ProjectId, TaskId, TicketId,
InboxSource, OpenProjectRestoreState, Project, ProjectId, TaskId, TicketId,
};
use serde_json::{json, Map, Value};
use uuid::Uuid;
@ -1815,6 +1815,19 @@ impl ManagedTempToolProvider for LateBoundManagedTempToolProvider {
}
}
/// Transport-neutral result of removing one project from the registry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoveProjectResult {
/// Removed registry entry. Its root remains untouched.
pub removed: Project,
/// Registry contents after removal.
pub projects: Vec<Project>,
/// Ordered open-project state after cleanup.
pub restore_state: OpenProjectRestoreState,
/// Whether the project was open in persisted or live runtime state.
pub was_open: bool,
}
/// Everything a driving adapter needs at runtime.
///
/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete
@ -1852,6 +1865,8 @@ pub struct BackendCore {
pub open_project: Arc<OpenProject>,
/// Close a project (persist state).
pub close_project: Arc<CloseProject>,
/// Remove a project from the known-project registry.
pub remove_project_from_registry: Arc<RemoveProjectFromRegistry>,
/// Read the ordered open-project restore intent.
pub read_open_project_restore_state: Arc<ReadOpenProjectRestoreState>,
/// Record a visibly opened project.
@ -1955,6 +1970,8 @@ pub struct BackendCore {
pub pty_port: Arc<dyn PtyPort>,
/// Active-terminal registry shared by the terminal use cases.
pub terminal_sessions: Arc<TerminalSessions>,
/// Unified live-session registry used for project-wide best-effort shutdown.
pub live_sessions: Arc<LiveSessions>,
/// The domain event bus (also handed to the event relay).
pub event_bus: Arc<TokioBroadcastEventBus>,
/// Create an issue-backed public ticket.
@ -2512,6 +2529,8 @@ impl BackendCore {
Arc::clone(&fs_port),
));
let close_project = Arc::new(CloseProject::new(Arc::clone(&store_port)));
let remove_project_from_registry =
Arc::new(RemoveProjectFromRegistry::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),
@ -4127,6 +4146,7 @@ impl BackendCore {
create_project,
open_project,
close_project,
remove_project_from_registry,
read_open_project_restore_state,
record_project_opened_for_restore,
record_project_closed_for_restore,
@ -4175,6 +4195,7 @@ impl BackendCore {
delete_model_artifact,
pty_port,
terminal_sessions,
live_sessions,
event_bus,
create_issue,
read_issue,
@ -4390,6 +4411,76 @@ impl BackendCore {
Ok(output)
}
/// Deactivates an open project best-effort, removes its restore intent, then
/// removes only its registry entry. The project root is never deleted.
pub async fn remove_project_and_deactivate(
&self,
project_id: ProjectId,
) -> Result<RemoveProjectResult, AppError> {
let project = self.project_store.load_project(project_id).await?;
let before = self.read_open_project_restore_state.execute().await?;
let was_open = before
.entries
.iter()
.any(|entry| entry.project_id == project_id)
|| self.open_project_ids().contains(&project_id)
|| self
.live_sessions
.live_agent_snapshots()
.iter()
.any(|snapshot| snapshot.project_id == project_id);
if was_open {
let _ = self
.snapshot_running_agents
.execute(SnapshotRunningAgentsInput { project_id })
.await;
self.stop_orchestrator_watch(&project_id);
let agent_ids = self
.live_sessions
.live_agent_snapshots()
.into_iter()
.filter(|snapshot| snapshot.project_id == project_id)
.map(|snapshot| snapshot.agent_id)
.collect::<HashSet<_>>();
for agent_id in agent_ids {
self.stop_turn_watch(project_id, agent_id);
let _ = application::terminal::stop_live_agent_runtime_in_project(
&project,
agent_id,
self.live_sessions.as_ref(),
self.close_terminal.as_ref(),
)
.await;
}
}
let restore_state = self
.record_project_closed_for_restore
.execute(project_id)
.await?;
let output = match self.remove_project_from_registry.execute(project_id).await {
Ok(output) => output,
Err(error) => {
if was_open {
let _ = self
.record_project_opened_for_restore
.execute(project.clone())
.await;
}
return Err(error);
}
};
Ok(RemoveProjectResult {
removed: output.removed,
projects: output.projects,
restore_state,
was_open,
})
}
/// 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
@ -8618,4 +8709,40 @@ mod open_project_restore_tests {
let _ = std::fs::remove_dir_all(&app_data);
let _ = std::fs::remove_dir_all(&roots);
}
#[tokio::test]
async fn remove_project_cleans_open_state_and_registry_without_deleting_root() {
let app_data = temp_path("remove-appdata");
let root = temp_path("remove-root");
let core = BackendCore::build(app_data.clone());
let project = create_project(&core, &root, "Removed").await;
core.record_project_opened_for_restore
.execute(project.clone())
.await
.expect("open state is recorded");
core.activate_project(&project).await;
let output = core
.remove_project_and_deactivate(project.id)
.await
.expect("registered project can be removed");
assert_eq!(output.removed, project);
assert!(output.was_open);
assert!(output.projects.is_empty());
assert!(output.restore_state.entries.is_empty());
assert!(!core.open_project_ids().contains(&output.removed.id));
assert!(root.exists(), "project root must remain on disk");
assert!(root.join(".ideai/project.json").exists());
assert_eq!(
core.remove_project_and_deactivate(output.removed.id)
.await
.unwrap_err()
.code(),
"NOT_FOUND"
);
let _ = std::fs::remove_dir_all(&app_data);
let _ = std::fs::remove_dir_all(&root);
}
}