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:
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user